From 1d062fa2f68bb4bc374834e094408ba5f11692c4 Mon Sep 17 00:00:00 2001 From: Luke Belton <58511679+luke-belton@users.noreply.github.com> Date: Fri, 8 Aug 2025 12:18:22 +0100 Subject: [PATCH 01/34] add new `isOptOut` method (#190) --- CHANGELOG.md | 2 ++ .../posthog/flutter/PosthogFlutterPlugin.kt | 13 +++++++++++++ example/lib/main.dart | 18 ++++++++++++++++++ ios/Classes/PosthogFlutterPlugin.swift | 7 +++++++ lib/src/posthog.dart | 2 ++ lib/src/posthog_flutter_io.dart | 15 +++++++++++++++ .../posthog_flutter_platform_interface.dart | 4 ++++ lib/src/posthog_flutter_web_handler.dart | 4 ++++ 8 files changed, 65 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3182f401..c5abcfd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- feat: add `isOptOut` method to check if the current user is opted out of data capture. ([#190](https://github.com/PostHog/posthog-flutter/pull/190)) + ## 5.1.0 - feat: surveys for iOS ([#188](https://github.com/PostHog/posthog-flutter/pull/188)) diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 46486271..12bea0cd 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -112,6 +112,10 @@ class PosthogFlutterPlugin : enable(result) } + "isOptOut" -> { + isOptOut(result) + } + "isFeatureEnabled" -> { isFeatureEnabled(call, result) } @@ -427,6 +431,15 @@ class PosthogFlutterPlugin : } } + private fun isOptOut(result: Result) { + try { + val isOptedOut = PostHog.isOptOut() + result.success(isOptedOut) + } catch (e: Throwable) { + result.error("PosthogFlutterException", e.localizedMessage, null) + } + } + private fun isFeatureEnabled( call: MethodCall, result: Result, diff --git a/example/lib/main.dart b/example/lib/main.dart index 05f0e819..264b0e78 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -148,6 +148,24 @@ class InitialScreenState extends State { }, child: const Text("Enable Capture"), ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + ), + onPressed: () async { + final isOptedOut = + await _posthogFlutterPlugin.isOptOut(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Opted out: $isOptedOut'), + duration: const Duration(seconds: 2), + ), + ); + } + }, + child: const Text("Check Opt-Out Status"), + ), ], ), ElevatedButton( diff --git a/ios/Classes/PosthogFlutterPlugin.swift b/ios/Classes/PosthogFlutterPlugin.swift index 56bf1996..62983780 100644 --- a/ios/Classes/PosthogFlutterPlugin.swift +++ b/ios/Classes/PosthogFlutterPlugin.swift @@ -182,6 +182,8 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { enable(result) case "disable": disable(result) + case "isOptOut": + isOptOut(result) case "debug": debug(call, result: result) case "reloadFeatureFlags": @@ -601,6 +603,11 @@ extension PosthogFlutterPlugin { result(nil) } + private func isOptOut(_ result: @escaping FlutterResult) { + let isOptedOut = PostHogSDK.shared.isOptOut() + result(isOptedOut) + } + private func debug( _ call: FlutterMethodCall, result: @escaping FlutterResult diff --git a/lib/src/posthog.dart b/lib/src/posthog.dart index 667ae96e..03ad89e0 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -89,6 +89,8 @@ class Posthog { Future enable() => _posthog.enable(); + Future isOptOut() => _posthog.isOptOut(); + Future debug(bool enabled) => _posthog.debug(enabled); Future register(String key, Object value) => diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index 2078731b..45e9ea53 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -257,6 +257,21 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } } + @override + Future isOptOut() async { + if (!isSupportedPlatform()) { + return true; + } + + try { + final result = await _methodChannel.invokeMethod('isOptOut'); + return result as bool? ?? true; + } on PlatformException catch (exception) { + printIfDebug('Exception on isOptOut: $exception'); + return true; + } + } + @override Future debug(bool enabled) async { if (!isSupportedPlatform()) { diff --git a/lib/src/posthog_flutter_platform_interface.dart b/lib/src/posthog_flutter_platform_interface.dart index 5123743b..8e1a5397 100644 --- a/lib/src/posthog_flutter_platform_interface.dart +++ b/lib/src/posthog_flutter_platform_interface.dart @@ -76,6 +76,10 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { throw UnimplementedError('enable() has not been implemented.'); } + Future isOptOut() { + throw UnimplementedError('isOptOut() has not been implemented.'); + } + Future debug(bool enabled) { throw UnimplementedError('debug() has not been implemented.'); } diff --git a/lib/src/posthog_flutter_web_handler.dart b/lib/src/posthog_flutter_web_handler.dart index 26f08db5..e8f2a357 100644 --- a/lib/src/posthog_flutter_web_handler.dart +++ b/lib/src/posthog_flutter_web_handler.dart @@ -23,6 +23,8 @@ extension PostHogExtension on PostHog { external void opt_in_capturing(); // ignore: non_constant_identifier_names external void opt_out_capturing(); + // ignore: non_constant_identifier_names + external bool has_opted_out_capturing(); external JSAny? getFeatureFlag(JSAny key); external JSAny? getFeatureFlagPayload(JSAny key); external void register(JSAny properties); @@ -146,6 +148,8 @@ Future handleWebMethodCall(MethodCall call) async { case 'disable': posthog?.opt_out_capturing(); break; + case 'isOptOut': + return posthog?.has_opted_out_capturing() ?? true; case 'getFeatureFlag': final key = args['key'] as String; From 0d74dd50658d0c1b22d0fde078d5ca465348de89 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 8 Aug 2025 13:19:34 +0200 Subject: [PATCH 02/34] changelog 5.2.0 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5abcfd9..0ba031b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +## 5.2.0 + - feat: add `isOptOut` method to check if the current user is opted out of data capture. ([#190](https://github.com/PostHog/posthog-flutter/pull/190)) ## 5.1.0 From 6a092cf6f98c9d6dea0d1da6638defc616af411b Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 8 Aug 2025 13:19:45 +0200 Subject: [PATCH 03/34] Update version --- android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt | 2 +- ios/Classes/PostHogFlutterVersion.swift | 2 +- pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt index 73e530b1..ac3e0243 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt @@ -1,3 +1,3 @@ package com.posthog.flutter -internal val postHogVersion = "5.1.0" +internal val postHogVersion = "5.2.0" diff --git a/ios/Classes/PostHogFlutterVersion.swift b/ios/Classes/PostHogFlutterVersion.swift index b3136ee8..634ecb58 100644 --- a/ios/Classes/PostHogFlutterVersion.swift +++ b/ios/Classes/PostHogFlutterVersion.swift @@ -8,4 +8,4 @@ import Foundation // This property is internal only -let postHogFlutterVersion = "5.1.0" +let postHogFlutterVersion = "5.2.0" diff --git a/pubspec.yaml b/pubspec.yaml index f3faf290..d662bf17 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: posthog_flutter description: Flutter implementation of PostHog client for iOS, Android and Web -version: 5.1.0 +version: 5.2.0 homepage: https://www.posthog.com repository: https://github.com/posthog/posthog-flutter issue_tracker: https://github.com/posthog/posthog-flutter/issues From fe933e8f169f80d4f5bd43e62630e9b6e88f3761 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> Date: Mon, 11 Aug 2025 12:16:52 +0200 Subject: [PATCH 04/34] chore: update languageVersion and apiVersion from 1.6 to 1.8 on Android to be compatible with Kotlin 2.2 (#193) --- CHANGELOG.md | 2 ++ android/build.gradle | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ba031b1..4179ee87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- chore: update languageVersion and apiVersion from 1.6 to 1.8 on Android to be compatible with Kotlin 2.2 ([#193](https://github.com/PostHog/posthog-flutter/pull/193)) + ## 5.2.0 - feat: add `isOptOut` method to check if the current user is opted out of data capture. ([#190](https://github.com/PostHog/posthog-flutter/pull/190)) diff --git a/android/build.gradle b/android/build.gradle index 1798f50d..6a795091 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -38,8 +38,8 @@ android { kotlinOptions { jvmTarget = '1.8' - languageVersion = "1.6" - apiVersion = "1.6" + languageVersion = "1.8" + apiVersion = "1.8" } sourceSets { From 46a11b23f28aac75b49ca2887111edbaf8c08dbe Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 11 Aug 2025 12:17:36 +0200 Subject: [PATCH 05/34] changelog 5.3.0 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4179ee87..cf7da5cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +## 5.3.0 + - chore: update languageVersion and apiVersion from 1.6 to 1.8 on Android to be compatible with Kotlin 2.2 ([#193](https://github.com/PostHog/posthog-flutter/pull/193)) ## 5.2.0 From ece79c45e5e771ee8b72347cb92300a242698e3a Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 11 Aug 2025 12:17:48 +0200 Subject: [PATCH 06/34] Update version --- android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt | 2 +- ios/Classes/PostHogFlutterVersion.swift | 2 +- pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt index ac3e0243..4354295a 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt @@ -1,3 +1,3 @@ package com.posthog.flutter -internal val postHogVersion = "5.2.0" +internal val postHogVersion = "5.3.0" diff --git a/ios/Classes/PostHogFlutterVersion.swift b/ios/Classes/PostHogFlutterVersion.swift index 634ecb58..8fe04216 100644 --- a/ios/Classes/PostHogFlutterVersion.swift +++ b/ios/Classes/PostHogFlutterVersion.swift @@ -8,4 +8,4 @@ import Foundation // This property is internal only -let postHogFlutterVersion = "5.2.0" +let postHogFlutterVersion = "5.3.0" diff --git a/pubspec.yaml b/pubspec.yaml index d662bf17..7ea05e61 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: posthog_flutter description: Flutter implementation of PostHog client for iOS, Android and Web -version: 5.2.0 +version: 5.3.0 homepage: https://www.posthog.com repository: https://github.com/posthog/posthog-flutter issue_tracker: https://github.com/posthog/posthog-flutter/issues From afa47e77c58b9151f7a0b3b0e1372995311aa128 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 22:53:24 +0200 Subject: [PATCH 07/34] chore(deps): bump actions/checkout from 4 to 5 (#195) --- .github/workflows/ci.yml | 2 +- .github/workflows/danger.yml | 2 +- .github/workflows/publish.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6454594..b776702c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: build: runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: 'Set up Java' uses: actions/setup-java@v4 diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 68e3645b..c134c108 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -8,7 +8,7 @@ jobs: name: Changelog runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - run: npx danger ci diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 666d6cd7..b9927ae7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest # use dart-lang/setup-dart/.github/workflows/publish.yml@v1 when https://github.com/dart-lang/setup-dart/issues/68 is fixed steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: dart-lang/setup-dart@v1 - uses: subosito/flutter-action@v2 with: From 237ba16d68d1cfcf3bcbc6535f202ad782da420b Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 13 Aug 2025 13:54:02 +0300 Subject: [PATCH 08/34] fix: do not render HTML content (#196) * fix: do not render HTML content * fix: address feedback --- CHANGELOG.md | 2 ++ ios/Classes/PostHogDisplaySurvey+Dict.swift | 3 +++ ios/posthog_flutter.podspec | 4 ++-- .../posthog_display_choice_question.dart | 1 + .../models/posthog_display_link_question.dart | 1 + .../models/posthog_display_open_question.dart | 1 + .../posthog_display_rating_question.dart | 1 + .../models/posthog_display_survey.dart | 20 +++++++++++++++++++ .../posthog_display_survey_appearance.dart | 4 ++++ .../posthog_display_survey_question.dart | 3 +++ ...thog_display_survey_text_content_type.dart | 20 +++++++++++++++++++ lib/src/surveys/widgets/choice_question.dart | 4 ++++ .../surveys/widgets/confirmation_message.dart | 8 +++++++- lib/src/surveys/widgets/link_question.dart | 4 ++++ .../surveys/widgets/open_text_question.dart | 4 ++++ lib/src/surveys/widgets/question_header.dart | 7 ++++++- lib/src/surveys/widgets/rating_question.dart | 4 ++++ .../surveys/widgets/survey_bottom_sheet.dart | 10 ++++++++++ 18 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 lib/src/surveys/models/posthog_display_survey_text_content_type.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index cf7da5cc..d803be84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- fix: don't render HTML content ([#196](https://github.com/PostHog/posthog-flutter/pull/196)) + ## 5.3.0 - chore: update languageVersion and apiVersion from 1.6 to 1.8 on Android to be compatible with Kotlin 2.2 ([#193](https://github.com/PostHog/posthog-flutter/pull/193)) diff --git a/ios/Classes/PostHogDisplaySurvey+Dict.swift b/ios/Classes/PostHogDisplaySurvey+Dict.swift index 55a1e6dc..3ad5d8e9 100644 --- a/ios/Classes/PostHogDisplaySurvey+Dict.swift +++ b/ios/Classes/PostHogDisplaySurvey+Dict.swift @@ -14,8 +14,10 @@ "question": question.question, "isOptional": question.isOptional, ] + if let desc = question.questionDescription { questionDict["questionDescription"] = desc + questionDict["questionDescriptionContentType"] = question.questionDescriptionContentType.rawValue } if let buttonText = question.buttonText { questionDict["buttonText"] = buttonText @@ -84,6 +86,7 @@ } if let thankYouMessageDescription = appearance.thankYouMessageDescription { appearanceDict["thankYouMessageDescription"] = thankYouMessageDescription + appearanceDict["thankYouMessageDescriptionContentType"] = appearance.thankYouMessageDescriptionContentType?.rawValue } if let thankYouMessageCloseButtonText = appearance.thankYouMessageCloseButtonText { appearanceDict["thankYouMessageCloseButtonText"] = thankYouMessageCloseButtonText diff --git a/ios/posthog_flutter.podspec b/ios/posthog_flutter.podspec index 6cf179e7..9212fb25 100644 --- a/ios/posthog_flutter.podspec +++ b/ios/posthog_flutter.podspec @@ -21,8 +21,8 @@ Postog flutter plugin s.ios.dependency 'Flutter' s.osx.dependency 'FlutterMacOS' - # ~> Version 3.29.0 up to, but not including, 4.0.0 - s.dependency 'PostHog', '~> 3.29' + # ~> Version 3.30.1 up to, but not including, 4.0.0 + s.dependency 'PostHog', '>= 3.30.1', '< 4.0.0' s.ios.deployment_target = '13.0' # PH iOS SDK 3.0.0 requires >= 10.15 diff --git a/lib/src/surveys/models/posthog_display_choice_question.dart b/lib/src/surveys/models/posthog_display_choice_question.dart index de51f0b9..d60f4259 100644 --- a/lib/src/surveys/models/posthog_display_choice_question.dart +++ b/lib/src/surveys/models/posthog_display_choice_question.dart @@ -12,6 +12,7 @@ class PostHogDisplayChoiceQuestion extends PostHogDisplaySurveyQuestion { this.hasOpenChoice = false, this.shuffleOptions = false, super.description, + super.descriptionContentType, super.optional, super.buttonText, }) : super( diff --git a/lib/src/surveys/models/posthog_display_link_question.dart b/lib/src/surveys/models/posthog_display_link_question.dart index 825de739..e6d43b07 100644 --- a/lib/src/surveys/models/posthog_display_link_question.dart +++ b/lib/src/surveys/models/posthog_display_link_question.dart @@ -9,6 +9,7 @@ class PostHogDisplayLinkQuestion extends PostHogDisplaySurveyQuestion { required super.question, required this.link, super.description, + super.descriptionContentType, super.optional, super.buttonText, }) : super( diff --git a/lib/src/surveys/models/posthog_display_open_question.dart b/lib/src/surveys/models/posthog_display_open_question.dart index 733d11bb..a73fde4e 100644 --- a/lib/src/surveys/models/posthog_display_open_question.dart +++ b/lib/src/surveys/models/posthog_display_open_question.dart @@ -8,6 +8,7 @@ class PostHogDisplayOpenQuestion extends PostHogDisplaySurveyQuestion { const PostHogDisplayOpenQuestion({ required super.question, super.description, + super.descriptionContentType, super.optional, super.buttonText, }) : super( diff --git a/lib/src/surveys/models/posthog_display_rating_question.dart b/lib/src/surveys/models/posthog_display_rating_question.dart index 271708e5..5be41d31 100644 --- a/lib/src/surveys/models/posthog_display_rating_question.dart +++ b/lib/src/surveys/models/posthog_display_rating_question.dart @@ -14,6 +14,7 @@ class PostHogDisplayRatingQuestion extends PostHogDisplaySurveyQuestion { required this.lowerBoundLabel, required this.upperBoundLabel, super.description, + super.descriptionContentType, super.optional, super.buttonText, }) : super( diff --git a/lib/src/surveys/models/posthog_display_survey.dart b/lib/src/surveys/models/posthog_display_survey.dart index 162aecce..dda52aab 100644 --- a/lib/src/surveys/models/posthog_display_survey.dart +++ b/lib/src/surveys/models/posthog_display_survey.dart @@ -6,6 +6,7 @@ import 'posthog_display_link_question.dart'; import 'posthog_display_rating_question.dart'; import 'posthog_display_choice_question.dart'; import 'posthog_display_survey_appearance.dart'; +import 'posthog_display_survey_text_content_type.dart'; /// Main survey model containing metadata and questions @immutable @@ -18,6 +19,12 @@ class PostHogDisplaySurvey { final question = q['question'] as String; final optional = q['isOptional'] as bool; final questionDescription = q['questionDescription'] as String?; + // Extract content type values with fallback to text (1) + final questionContentTypeRaw = + q['questionDescriptionContentType'] as int? ?? 1; + final questionDescriptionContentType = + PostHogDisplaySurveyTextContentType.fromInt(questionContentTypeRaw); + final buttonText = q['buttonText'] as String?; switch (type) { @@ -26,6 +33,7 @@ class PostHogDisplaySurvey { question: question, link: q['link'] as String, description: questionDescription, + descriptionContentType: questionDescriptionContentType, optional: optional, buttonText: buttonText, ); @@ -39,6 +47,7 @@ class PostHogDisplaySurvey { lowerBoundLabel: q['lowerBoundLabel'] as String, upperBoundLabel: q['upperBoundLabel'] as String, description: questionDescription, + descriptionContentType: questionDescriptionContentType, optional: optional, buttonText: buttonText, ); @@ -51,6 +60,7 @@ class PostHogDisplaySurvey { hasOpenChoice: q['hasOpenChoice'] as bool, shuffleOptions: q['shuffleOptions'] as bool, description: questionDescription, + descriptionContentType: questionDescriptionContentType, optional: optional, buttonText: buttonText, ); @@ -59,6 +69,7 @@ class PostHogDisplaySurvey { return PostHogDisplayOpenQuestion( question: question, description: questionDescription, + descriptionContentType: questionDescriptionContentType, optional: optional, buttonText: buttonText, ); @@ -68,6 +79,13 @@ class PostHogDisplaySurvey { PostHogDisplaySurveyAppearance? appearance; if (dict['appearance'] != null) { final a = Map.from(dict['appearance'] as Map); + + // Extract thank you message content type with fallback to text (1) + final thankYouContentTypeRaw = + a['thankYouMessageDescriptionContentType'] as int? ?? 1; + final thankYouMessageDescriptionContentType = + PostHogDisplaySurveyTextContentType.fromInt(thankYouContentTypeRaw); + appearance = PostHogDisplaySurveyAppearance( fontFamily: a['fontFamily'] as String?, backgroundColor: a['backgroundColor'] as String?, @@ -82,6 +100,8 @@ class PostHogDisplaySurvey { displayThankYouMessage: a['displayThankYouMessage'] as bool? ?? true, thankYouMessageHeader: a['thankYouMessageHeader'] as String?, thankYouMessageDescription: a['thankYouMessageDescription'] as String?, + thankYouMessageDescriptionContentType: + thankYouMessageDescriptionContentType, thankYouMessageCloseButtonText: a['thankYouMessageCloseButtonText'] as String?, ); diff --git a/lib/src/surveys/models/posthog_display_survey_appearance.dart b/lib/src/surveys/models/posthog_display_survey_appearance.dart index d699b6bb..40ce961c 100644 --- a/lib/src/surveys/models/posthog_display_survey_appearance.dart +++ b/lib/src/surveys/models/posthog_display_survey_appearance.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'posthog_display_survey_text_content_type.dart'; /// Appearance configuration for surveys @immutable @@ -17,6 +18,7 @@ class PostHogDisplaySurveyAppearance { this.displayThankYouMessage = true, this.thankYouMessageHeader, this.thankYouMessageDescription, + this.thankYouMessageDescriptionContentType, this.thankYouMessageCloseButtonText, }); @@ -33,5 +35,7 @@ class PostHogDisplaySurveyAppearance { final bool displayThankYouMessage; final String? thankYouMessageHeader; final String? thankYouMessageDescription; + final PostHogDisplaySurveyTextContentType? + thankYouMessageDescriptionContentType; final String? thankYouMessageCloseButtonText; } diff --git a/lib/src/surveys/models/posthog_display_survey_question.dart b/lib/src/surveys/models/posthog_display_survey_question.dart index 67d04a4b..727335ab 100644 --- a/lib/src/surveys/models/posthog_display_survey_question.dart +++ b/lib/src/surveys/models/posthog_display_survey_question.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'posthog_survey_question_type.dart'; +import 'posthog_display_survey_text_content_type.dart'; /// Base class for all survey questions @immutable @@ -8,6 +9,7 @@ abstract class PostHogDisplaySurveyQuestion { required this.type, required this.question, this.description, + this.descriptionContentType = PostHogDisplaySurveyTextContentType.text, this.optional = false, this.buttonText, }); @@ -15,6 +17,7 @@ abstract class PostHogDisplaySurveyQuestion { final PostHogSurveyQuestionType type; final String question; final String? description; + final PostHogDisplaySurveyTextContentType descriptionContentType; final bool optional; final String? buttonText; } diff --git a/lib/src/surveys/models/posthog_display_survey_text_content_type.dart b/lib/src/surveys/models/posthog_display_survey_text_content_type.dart new file mode 100644 index 00000000..b99c926d --- /dev/null +++ b/lib/src/surveys/models/posthog_display_survey_text_content_type.dart @@ -0,0 +1,20 @@ +/// Content type for text-based survey elements +enum PostHogDisplaySurveyTextContentType { + /// Content should be rendered as HTML + html(0), + + /// Content should be rendered as plain text + text(1); + + const PostHogDisplaySurveyTextContentType(this.value); + + final int value; + + /// Create from raw int value + static PostHogDisplaySurveyTextContentType fromInt(int value) { + return PostHogDisplaySurveyTextContentType.values.firstWhere( + (e) => e.value == value, + orElse: () => PostHogDisplaySurveyTextContentType.text, // Default to text + ); + } +} diff --git a/lib/src/surveys/widgets/choice_question.dart b/lib/src/surveys/widgets/choice_question.dart index 7416616f..6a686776 100644 --- a/lib/src/surveys/widgets/choice_question.dart +++ b/lib/src/surveys/widgets/choice_question.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../models/survey_appearance.dart'; +import '../models/posthog_display_survey_text_content_type.dart'; import 'question_header.dart'; import 'survey_button.dart'; import 'survey_choice_button.dart'; @@ -10,6 +11,7 @@ class ChoiceQuestionWidget extends StatefulWidget { super.key, required this.question, required this.description, + this.descriptionContentType, required this.choices, required this.appearance, this.buttonText, @@ -21,6 +23,7 @@ class ChoiceQuestionWidget extends StatefulWidget { final String question; final String? description; + final PostHogDisplaySurveyTextContentType? descriptionContentType; final List choices; final SurveyAppearance appearance; final String? buttonText; @@ -104,6 +107,7 @@ class _ChoiceQuestionWidgetState extends State { QuestionHeader( question: widget.question, description: widget.description, + descriptionContentType: widget.descriptionContentType, appearance: widget.appearance, ), const SizedBox(height: 16), diff --git a/lib/src/surveys/widgets/confirmation_message.dart b/lib/src/surveys/widgets/confirmation_message.dart index a7eb7089..cd6ba032 100644 --- a/lib/src/surveys/widgets/confirmation_message.dart +++ b/lib/src/surveys/widgets/confirmation_message.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../models/survey_appearance.dart'; +import '../models/posthog_display_survey_text_content_type.dart'; import 'survey_button.dart'; class ConfirmationMessage extends StatelessWidget { @@ -7,10 +8,13 @@ class ConfirmationMessage extends StatelessWidget { super.key, required this.onClose, this.appearance = SurveyAppearance.defaultAppearance, + this.thankYouMessageDescriptionContentType, }); final VoidCallback onClose; final SurveyAppearance appearance; + final PostHogDisplaySurveyTextContentType? + thankYouMessageDescriptionContentType; @override Widget build(BuildContext context) { @@ -27,7 +31,9 @@ class ConfirmationMessage extends StatelessWidget { ), textAlign: TextAlign.center, ), - if (appearance.thankYouMessageDescription?.isNotEmpty == true) ...[ + if (appearance.thankYouMessageDescription?.isNotEmpty == true && + thankYouMessageDescriptionContentType == + PostHogDisplaySurveyTextContentType.text) ...[ const SizedBox(height: 16), Text( appearance.thankYouMessageDescription!, diff --git a/lib/src/surveys/widgets/link_question.dart b/lib/src/surveys/widgets/link_question.dart index 2b7a8a0b..3c5d655b 100644 --- a/lib/src/surveys/widgets/link_question.dart +++ b/lib/src/surveys/widgets/link_question.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../models/survey_appearance.dart'; +import '../models/posthog_display_survey_text_content_type.dart'; import 'question_header.dart'; import 'survey_button.dart'; @@ -10,6 +11,7 @@ class LinkQuestion extends StatelessWidget { super.key, required this.question, required this.description, + this.descriptionContentType, required this.appearance, required this.onPressed, this.buttonText, @@ -18,6 +20,7 @@ class LinkQuestion extends StatelessWidget { final String question; final String? description; + final PostHogDisplaySurveyTextContentType? descriptionContentType; final SurveyAppearance appearance; final Future Function() onPressed; final String? buttonText; @@ -32,6 +35,7 @@ class LinkQuestion extends StatelessWidget { QuestionHeader( question: question, description: description, + descriptionContentType: descriptionContentType, appearance: appearance, ), const SizedBox(height: 16), diff --git a/lib/src/surveys/widgets/open_text_question.dart b/lib/src/surveys/widgets/open_text_question.dart index c7271aeb..d00caace 100644 --- a/lib/src/surveys/widgets/open_text_question.dart +++ b/lib/src/surveys/widgets/open_text_question.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../models/survey_appearance.dart'; +import '../models/posthog_display_survey_text_content_type.dart'; import 'question_header.dart'; import 'survey_button.dart'; @@ -8,6 +9,7 @@ class OpenTextQuestion extends StatefulWidget { super.key, required this.question, required this.description, + this.descriptionContentType, required this.onSubmit, this.buttonText = 'Submit', this.optional = false, @@ -16,6 +18,7 @@ class OpenTextQuestion extends StatefulWidget { final String? question; final String? description; + final PostHogDisplaySurveyTextContentType? descriptionContentType; final String buttonText; final bool optional; final SurveyAppearance appearance; @@ -53,6 +56,7 @@ class _OpenTextQuestionState extends State { QuestionHeader( question: widget.question, description: widget.description, + descriptionContentType: widget.descriptionContentType, appearance: widget.appearance, ), const SizedBox(height: 16), diff --git a/lib/src/surveys/widgets/question_header.dart b/lib/src/surveys/widgets/question_header.dart index 69449c15..9e608de4 100644 --- a/lib/src/surveys/widgets/question_header.dart +++ b/lib/src/surveys/widgets/question_header.dart @@ -1,16 +1,19 @@ import 'package:flutter/material.dart'; import '../models/survey_appearance.dart'; +import '../models/posthog_display_survey_text_content_type.dart'; class QuestionHeader extends StatelessWidget { const QuestionHeader({ super.key, required this.question, this.description, + this.descriptionContentType, required this.appearance, }); final String? question; final String? description; + final PostHogDisplaySurveyTextContentType? descriptionContentType; final SurveyAppearance appearance; @override @@ -28,7 +31,9 @@ class QuestionHeader extends StatelessWidget { ), ), ], - if (description?.isNotEmpty == true) ...[ + if (description?.isNotEmpty == true && + descriptionContentType == + PostHogDisplaySurveyTextContentType.text) ...[ const SizedBox(height: 8), Text( description!, diff --git a/lib/src/surveys/widgets/rating_question.dart b/lib/src/surveys/widgets/rating_question.dart index 2acb81fd..f5162460 100644 --- a/lib/src/surveys/widgets/rating_question.dart +++ b/lib/src/surveys/widgets/rating_question.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../models/posthog_display_survey_rating_type.dart'; +import '../models/posthog_display_survey_text_content_type.dart'; import '../models/survey_appearance.dart'; import 'question_header.dart'; import 'survey_button.dart'; @@ -11,6 +12,7 @@ class RatingQuestion extends StatefulWidget { super.key, required this.question, required this.description, + this.descriptionContentType, required this.onSubmit, this.buttonText, this.optional = false, @@ -24,6 +26,7 @@ class RatingQuestion extends StatefulWidget { final String? question; final String? description; + final PostHogDisplaySurveyTextContentType? descriptionContentType; final String? buttonText; final bool optional; final int scaleLowerBound; @@ -147,6 +150,7 @@ class _RatingQuestionState extends State { QuestionHeader( question: widget.question, description: widget.description, + descriptionContentType: widget.descriptionContentType, appearance: widget.appearance, ), const SizedBox(height: 24), diff --git a/lib/src/surveys/widgets/survey_bottom_sheet.dart b/lib/src/surveys/widgets/survey_bottom_sheet.dart index 8932e09d..8653e40f 100644 --- a/lib/src/surveys/widgets/survey_bottom_sheet.dart +++ b/lib/src/surveys/widgets/survey_bottom_sheet.dart @@ -7,6 +7,7 @@ import '../models/survey_callbacks.dart'; import '../models/posthog_display_link_question.dart'; import '../models/posthog_display_rating_question.dart'; import '../models/posthog_display_choice_question.dart'; +import '../models/posthog_display_survey_text_content_type.dart'; import '../../posthog_flutter_platform_interface.dart'; import 'link_question.dart'; @@ -61,6 +62,7 @@ class _SurveyBottomSheetState extends State { key: ValueKey('open_text_question_$_currentIndex'), question: currentQuestion.question, description: currentQuestion.description, + descriptionContentType: currentQuestion.descriptionContentType, appearance: SurveyAppearance.fromPostHog(widget.survey.appearance), onSubmit: (response) async { final nextQuestion = await widget.onResponse( @@ -80,6 +82,7 @@ class _SurveyBottomSheetState extends State { key: ValueKey('link_question_$_currentIndex'), question: linkQuestion.question, description: linkQuestion.description, + descriptionContentType: linkQuestion.descriptionContentType, appearance: SurveyAppearance.fromPostHog(widget.survey.appearance), buttonText: linkQuestion.buttonText, link: linkQuestion.link, @@ -111,6 +114,7 @@ class _SurveyBottomSheetState extends State { key: ValueKey('rating_question_$_currentIndex'), question: ratingQuestion.question, description: ratingQuestion.description, + descriptionContentType: ratingQuestion.descriptionContentType, appearance: SurveyAppearance.fromPostHog(widget.survey.appearance), buttonText: ratingQuestion.buttonText, optional: ratingQuestion.optional, @@ -138,6 +142,7 @@ class _SurveyBottomSheetState extends State { key: ValueKey('choice_question_$_currentIndex'), question: choiceQuestion.question, description: choiceQuestion.description, + descriptionContentType: choiceQuestion.descriptionContentType, choices: choiceQuestion.choices, appearance: SurveyAppearance.fromPostHog(widget.survey.appearance), buttonText: choiceQuestion.buttonText, @@ -208,6 +213,11 @@ class _SurveyBottomSheetState extends State { ConfirmationMessage( onClose: _handleClose, appearance: widget.appearance, + thankYouMessageDescriptionContentType: widget + .survey + .appearance + ?.thankYouMessageDescriptionContentType ?? + PostHogDisplaySurveyTextContentType.text, ), ], ), From aea5b40fe2954ed0f712b80170a9151bdead5762 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 13 Aug 2025 13:55:15 +0300 Subject: [PATCH 09/34] Update version --- CHANGELOG.md | 2 ++ android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt | 2 +- ios/Classes/PostHogFlutterVersion.swift | 2 +- pubspec.yaml | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d803be84..e9e36f65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +## 5.3.1 + - fix: don't render HTML content ([#196](https://github.com/PostHog/posthog-flutter/pull/196)) ## 5.3.0 diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt index 4354295a..6be94696 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt @@ -1,3 +1,3 @@ package com.posthog.flutter -internal val postHogVersion = "5.3.0" +internal val postHogVersion = "5.3.1" diff --git a/ios/Classes/PostHogFlutterVersion.swift b/ios/Classes/PostHogFlutterVersion.swift index 8fe04216..de95ec30 100644 --- a/ios/Classes/PostHogFlutterVersion.swift +++ b/ios/Classes/PostHogFlutterVersion.swift @@ -8,4 +8,4 @@ import Foundation // This property is internal only -let postHogFlutterVersion = "5.3.0" +let postHogFlutterVersion = "5.3.1" diff --git a/pubspec.yaml b/pubspec.yaml index 7ea05e61..5afdaeee 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: posthog_flutter description: Flutter implementation of PostHog client for iOS, Android and Web -version: 5.3.0 +version: 5.3.1 homepage: https://www.posthog.com repository: https://github.com/posthog/posthog-flutter issue_tracker: https://github.com/posthog/posthog-flutter/issues From 12aae3c3459ffdd71806c4cbc6beba476e72284b Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> Date: Tue, 19 Aug 2025 12:52:44 +0200 Subject: [PATCH 10/34] Update CODEOWNERS (#197) --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fd89ad1d..c7d1b81c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @marandaneto +* @PostHog/team-mobile From 1f0b96ea40abf9314d89d92e4a8d097ced884cc5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Aug 2025 11:53:16 +0200 Subject: [PATCH 11/34] chore(deps): bump actions/setup-java from 4 to 5 (#199) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b776702c..bbe47fe2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v5 - name: 'Set up Java' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: java-version: 11 distribution: 'temurin' From ad2c89a4774d9daf764786191204a48c635846a2 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 28 Aug 2025 14:46:47 +0300 Subject: [PATCH 12/34] feat: add surveys support on Android (#198) * feat: add android plugin * fix: kotlin surveys delegate * chore: update CHANGELOG * ci: failing builds * chore: update dependency version * ci: fix * ci: fix example build * ci: update to java 17 --- .github/workflows/ci.yml | 7 +- CHANGELOG.md | 2 + android/build.gradle | 4 +- .../flutter/PostHogDisplaySurveyExt.kt | 88 +++++++++++ .../flutter/PostHogFlutterSurveysDelegate.kt | 146 ++++++++++++++++++ .../posthog/flutter/PosthogFlutterPlugin.kt | 86 ++++++++++- example/android/app/build.gradle | 2 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- example/android/settings.gradle | 2 +- 9 files changed, 329 insertions(+), 10 deletions(-) create mode 100644 android/src/main/kotlin/com/posthog/flutter/PostHogDisplaySurveyExt.kt create mode 100644 android/src/main/kotlin/com/posthog/flutter/PostHogFlutterSurveysDelegate.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbe47fe2..4935167e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,13 +17,18 @@ jobs: - name: 'Set up Java' uses: actions/setup-java@v5 with: - java-version: 11 + java-version: 17 distribution: 'temurin' - uses: dart-lang/setup-dart@v1 - uses: subosito/flutter-action@v2 with: channel: 'stable' + + - name: Select Xcode version + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable - name: Install dependencies run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e36f65..0200eb82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- feat: surveys for Android ([#198](https://github.com/PostHog/posthog-flutter/pull/198)) + ## 5.3.1 - fix: don't render HTML content ([#196](https://github.com/PostHog/posthog-flutter/pull/196)) diff --git a/android/build.gradle b/android/build.gradle index 6a795091..3195ad97 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -54,8 +54,8 @@ android { dependencies { testImplementation 'org.jetbrains.kotlin:kotlin-test' testImplementation 'org.mockito:mockito-core:5.0.0' - // + Version 3.+ and the versions up to 4.0, not including 4.0 and higher - implementation 'com.posthog:posthog-android:3.+' + // + Version 3.21.0 and the versions up to 4.0.0, not including 4.0.0 and higher + implementation 'com.posthog:posthog-android:[3.21.0,4.0.0]' } testOptions { diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogDisplaySurveyExt.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogDisplaySurveyExt.kt new file mode 100644 index 00000000..a5604849 --- /dev/null +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogDisplaySurveyExt.kt @@ -0,0 +1,88 @@ +package com.posthog.flutter + +import com.posthog.surveys.PostHogDisplayChoiceQuestion +import com.posthog.surveys.PostHogDisplayLinkQuestion +import com.posthog.surveys.PostHogDisplayOpenQuestion +import com.posthog.surveys.PostHogDisplayRatingQuestion +import com.posthog.surveys.PostHogDisplaySurvey +import com.posthog.surveys.PostHogDisplaySurveyQuestion + +// Convert the survey object to a map for communication with the Dart layer +// Native platform model -> Map -> Dart model +fun PostHogDisplaySurvey.toMap(): Map { + val map = + mutableMapOf( + "id" to id, + "name" to name, + "questions" to + questions.map { question: PostHogDisplaySurveyQuestion -> + val questionMap = + mutableMapOf( + "question" to question.question, + "isOptional" to question.isOptional, + ) + + questionMap["questionDescription"] = question.questionDescription + questionMap["questionDescriptionContentType"] = question.questionDescriptionContentType?.value + questionMap["buttonText"] = question.buttonText + + // Add question type-specific properties + when (question) { + is PostHogDisplayLinkQuestion -> { + questionMap["type"] = "link" + questionMap["link"] = question.link + } + is PostHogDisplayRatingQuestion -> { + questionMap["type"] = "rating" + questionMap["ratingType"] = question.ratingType.value + questionMap["scaleLowerBound"] = question.scaleLowerBound + questionMap["scaleUpperBound"] = question.scaleUpperBound + questionMap["lowerBoundLabel"] = question.lowerBoundLabel + questionMap["upperBoundLabel"] = question.upperBoundLabel + } + is PostHogDisplayChoiceQuestion -> { + questionMap["type"] = if (question.isMultipleChoice) "multiple_choice" else "single_choice" + questionMap["choices"] = question.choices + questionMap["hasOpenChoice"] = question.hasOpenChoice + questionMap["shuffleOptions"] = question.shuffleOptions + } + else -> { + questionMap["type"] = "open" + } + } + + questionMap + }, + ) + + // Add appearance if available + appearance?.let { app -> + map["appearance"] = + mapOf( + "backgroundColor" to app.backgroundColor, + "submitButtonColor" to app.submitButtonColor, + "submitButtonText" to app.submitButtonText, + "submitButtonTextColor" to app.submitButtonTextColor, + "descriptionTextColor" to app.descriptionTextColor, + "ratingButtonColor" to app.ratingButtonColor, + "ratingButtonActiveColor" to app.ratingButtonActiveColor, + "borderColor" to app.borderColor, + "placeholder" to app.placeholder, + "displayThankYouMessage" to app.displayThankYouMessage, + "thankYouMessageHeader" to app.thankYouMessageHeader, + "thankYouMessageDescription" to app.thankYouMessageDescription, + "thankYouMessageDescriptionContentType" to app.thankYouMessageDescriptionContentType?.value, + ) + } + + // Add dates if available (convert to milliseconds since epoch) + startDate?.let { date -> + map["startDate"] = date.time + } + + endDate?.let { date -> + map["endDate"] = date.time + } + + return map +} diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogFlutterSurveysDelegate.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogFlutterSurveysDelegate.kt new file mode 100644 index 00000000..b1701855 --- /dev/null +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogFlutterSurveysDelegate.kt @@ -0,0 +1,146 @@ +package com.posthog.flutter + +import android.os.Handler +import android.os.Looper +import com.posthog.surveys.OnPostHogSurveyClosed +import com.posthog.surveys.OnPostHogSurveyResponse +import com.posthog.surveys.OnPostHogSurveyShown +import com.posthog.surveys.PostHogDisplayChoiceQuestion +import com.posthog.surveys.PostHogDisplayLinkQuestion +import com.posthog.surveys.PostHogDisplayOpenQuestion +import com.posthog.surveys.PostHogDisplayRatingQuestion +import com.posthog.surveys.PostHogDisplaySurvey +import com.posthog.surveys.PostHogDisplaySurveyQuestion +import com.posthog.surveys.PostHogNextSurveyQuestion +import com.posthog.surveys.PostHogSurveyResponse +import com.posthog.surveys.PostHogSurveysDelegate +import io.flutter.plugin.common.MethodChannel + +/** + * Separate surveys delegate to avoid class loading issues in the main plugin + */ +class PostHogFlutterSurveysDelegate( + private val channel: MethodChannel, +) : PostHogSurveysDelegate { + private var currentSurvey: PostHogDisplaySurvey? = null + private var onSurveyShownCallback: OnPostHogSurveyShown? = null + private var onSurveyResponseCallback: OnPostHogSurveyResponse? = null + private var onSurveyClosedCallback: OnPostHogSurveyClosed? = null + + override fun renderSurvey( + survey: PostHogDisplaySurvey, + onSurveyShown: OnPostHogSurveyShown, + onSurveyResponse: OnPostHogSurveyResponse, + onSurveyClosed: OnPostHogSurveyClosed, + ) { + currentSurvey = survey + onSurveyShownCallback = onSurveyShown + onSurveyResponseCallback = onSurveyResponse + onSurveyClosedCallback = onSurveyClosed + + // Convert survey to map and send to Flutter + invokeFlutterMethod("showSurvey", survey.toMap()) + } + + override fun cleanupSurveys() { + currentSurvey = null + onSurveyShownCallback = null + onSurveyResponseCallback = null + onSurveyClosedCallback = null + } + + fun handleSurveyAction( + action: String, + payload: Map?, + result: MethodChannel.Result, + ) { + val survey = currentSurvey + if (survey == null) { + result.error("InvalidArguments", "No active survey", null) + return + } + + when (action) { + "shown" -> { + onSurveyShownCallback?.invoke(survey) + } + "response" -> { + val index = payload?.get("index") as? Int + val responsePayload = payload?.get("response") + + if (index != null && responsePayload != null && index < survey.questions.size) { + val question = survey.questions[index] + + // Create PostHogSurveyResponse based on question type + val surveyResponse = + when (question) { + is PostHogDisplayLinkQuestion -> { + // For link questions + val boolValue = responsePayload as? Boolean ?: false + PostHogSurveyResponse.Link(boolValue) + } + is PostHogDisplayRatingQuestion -> { + // For rating questions + val ratingValue = responsePayload as? Int + PostHogSurveyResponse.Rating(ratingValue) + } + is PostHogDisplayChoiceQuestion -> { + // For single/multiple choice questions + if (question.isMultipleChoice) { + // Multiple choice: accept array directly from Flutter + val selectedOptions = responsePayload as? List<*> + val stringOptions = selectedOptions?.mapNotNull { it as? String } + PostHogSurveyResponse.MultipleChoice(stringOptions ?: emptyList()) + } else { + // Single choice: Flutter sends as a list with one element + val selectedOptions = responsePayload as? List<*> + val firstOption = selectedOptions?.firstOrNull() as? String + PostHogSurveyResponse.SingleChoice(firstOption) + } + } + else -> { + // Default to open text question + val textValue = responsePayload as? String + PostHogSurveyResponse.Text(textValue) + } + } + + // Call the callback with the constructed response + onSurveyResponseCallback?.invoke(survey, index, surveyResponse)?.let { nextQuestion -> + result.success( + mapOf( + "nextIndex" to nextQuestion.questionIndex, + "isSurveyCompleted" to nextQuestion.isSurveyCompleted, + ), + ) + return + } + result.success(null) + return + } + } + "closed" -> { + onSurveyClosedCallback?.invoke(survey) + // Clear the callbacks after survey is closed + currentSurvey = null + onSurveyShownCallback = null + onSurveyResponseCallback = null + onSurveyClosedCallback = null + } + } + + result.success(null) + } + + /** + * Invoke a Flutter method on the main/UI thread + */ + private fun invokeFlutterMethod( + method: String, + arguments: Any? = null, + ) { + Handler(Looper.getMainLooper()).post { + channel.invokeMethod(method, arguments) + } + } +} diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 12bea0cd..25d8f6db 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -1,7 +1,12 @@ package com.posthog.flutter +import android.content.ActivityNotFoundException import android.content.Context +import android.content.Intent +import android.net.Uri import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.util.Log import com.posthog.PersonProfiles import com.posthog.PostHog @@ -29,6 +34,9 @@ class PosthogFlutterPlugin : private val snapshotSender = SnapshotSender() + // The surveys delegate + private var flutterSurveysDelegate: PostHogFlutterSurveysDelegate? = null + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "posthog_flutter") @@ -270,6 +278,17 @@ class PosthogFlutterPlugin : this.sessionReplayConfig.captureLogcat = false + // Configure surveys + posthogConfig.getIfNotNull("surveys") { + surveys = it + if (surveys) { + // If surveys are enabled, create and assign the surveys delegate + val delegate = PostHogFlutterSurveysDelegate(channel) + surveysConfig.surveysDelegate = delegate + flutterSurveysDelegate = delegate + } + } + sdkName = "posthog-flutter" sdkVersion = postHogVersion } @@ -546,15 +565,74 @@ class PosthogFlutterPlugin : call: MethodCall, result: Result, ) { - // TODO: Not implemented - result.success(null) + try { + val raw = (call.arguments as? String)?.trim() + if (raw.isNullOrEmpty()) { + result.error("InvalidArguments", "URL is null or empty", null) + return + } + + var uri = try { + Uri.parse(raw) + } catch (e: Throwable) { + result.error("InvalidArguments", "Malformed URL: $raw", null) + return + } + + // If no scheme provided (e.g., "example.com"), default to https:// + if (uri.scheme.isNullOrEmpty()) { + uri = Uri.parse("https://$raw") + } + + val intent = Intent(Intent.ACTION_VIEW, uri).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + try { + applicationContext.startActivity(intent) + result.success(null) + } catch (e: ActivityNotFoundException) { + result.error("ActivityNotFound", "No application can handle ACTION_VIEW for the given URL", null) + } + } catch (e: Throwable) { + result.error("PosthogFlutterException", e.localizedMessage, null) + } + } + + private fun invokeFlutterMethod( + method: String, + arguments: Any? = null, + ) { + if (Looper.myLooper() == Looper.getMainLooper()) { + channel.invokeMethod(method, arguments) + } else { + Handler(Looper.getMainLooper()).post { + channel.invokeMethod(method, arguments) + } + } } + // MARK: - Survey Action Handling + private fun handleSurveyAction( call: MethodCall, result: Result, ) { - // TODO: Not implemented - result.success(null) + val args = call.arguments as? Map + val type = args?.get("type") as? String + + // Check for invalid arguments + if (args == null || type == null) { + result.error("InvalidArguments", "Invalid survey action arguments", null) + return + } + + if (flutterSurveysDelegate == null) { + result.error("InvalidArguments", "Survey delegate not available", null) + return + } + + flutterSurveysDelegate?.handleSurveyAction(type, args, result) } } diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index a346b119..a7fe5185 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -45,7 +45,7 @@ android { applicationId "com.example.flutter" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. - minSdkVersion 21 + minSdkVersion flutter.minSdkVersion targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index 1af9e093..b82aa23a 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/example/android/settings.gradle b/example/android/settings.gradle index fd066b7e..7d6df500 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -23,7 +23,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.4.2" apply false + id "com.android.application" version "8.1.1" apply false } include ":app" From 42c130df874f1bd0fb3134b119b36a287909dd4a Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Thu, 28 Aug 2025 14:49:16 +0300 Subject: [PATCH 13/34] Update version --- CHANGELOG.md | 3 +++ android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt | 2 +- ios/Classes/PostHogFlutterVersion.swift | 2 +- pubspec.yaml | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0200eb82..807cd14c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ ## Next +## 5.4.0 + - feat: surveys for Android ([#198](https://github.com/PostHog/posthog-flutter/pull/198)) + - See how to setup in [Surveys docs](https://posthog.com/docs/surveys/installation?tab=Flutter) ## 5.3.1 diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt index 6be94696..12598f2c 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt @@ -1,3 +1,3 @@ package com.posthog.flutter -internal val postHogVersion = "5.3.1" +internal val postHogVersion = "5.4.0" diff --git a/ios/Classes/PostHogFlutterVersion.swift b/ios/Classes/PostHogFlutterVersion.swift index de95ec30..6c9603ce 100644 --- a/ios/Classes/PostHogFlutterVersion.swift +++ b/ios/Classes/PostHogFlutterVersion.swift @@ -8,4 +8,4 @@ import Foundation // This property is internal only -let postHogFlutterVersion = "5.3.1" +let postHogFlutterVersion = "5.4.0" diff --git a/pubspec.yaml b/pubspec.yaml index 5afdaeee..65be001e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: posthog_flutter description: Flutter implementation of PostHog client for iOS, Android and Web -version: 5.3.1 +version: 5.4.0 homepage: https://www.posthog.com repository: https://github.com/posthog/posthog-flutter issue_tracker: https://github.com/posthog/posthog-flutter/issues From be6438d10fd64075f94bafd67f9bb0a15989e3fb Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 29 Aug 2025 17:51:33 +0300 Subject: [PATCH 14/34] feat: surveys iOS GA (#202) * feat: surveys iOS GA * Update CHANGELOG.md Co-authored-by: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> * fix: default to false for survey configuration --------- Co-authored-by: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> --- CHANGELOG.md | 4 ++++ .../posthog/flutter/PosthogFlutterPlugin.kt | 22 ++++++++++--------- ios/Classes/PosthogFlutterPlugin.swift | 13 +++++------ ios/posthog_flutter.podspec | 4 ++-- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 807cd14c..94092572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Next +## 5.4.1 + +- chore: update posthog-ios dependency to min. 3.31.0 (([#202](https://github.com/PostHog/posthog-flutter/pull/202))) + ## 5.4.0 - feat: surveys for Android ([#198](https://github.com/PostHog/posthog-flutter/pull/198)) diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 25d8f6db..380e8d5a 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -572,22 +572,24 @@ class PosthogFlutterPlugin : return } - var uri = try { - Uri.parse(raw) - } catch (e: Throwable) { - result.error("InvalidArguments", "Malformed URL: $raw", null) - return - } + var uri = + try { + Uri.parse(raw) + } catch (e: Throwable) { + result.error("InvalidArguments", "Malformed URL: $raw", null) + return + } // If no scheme provided (e.g., "example.com"), default to https:// if (uri.scheme.isNullOrEmpty()) { uri = Uri.parse("https://$raw") } - val intent = Intent(Intent.ACTION_VIEW, uri).apply { - addCategory(Intent.CATEGORY_BROWSABLE) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } + val intent = + Intent(Intent.ACTION_VIEW, uri).apply { + addCategory(Intent.CATEGORY_BROWSABLE) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } try { applicationContext.startActivity(intent) diff --git a/ios/Classes/PosthogFlutterPlugin.swift b/ios/Classes/PosthogFlutterPlugin.swift index 62983780..9bac3192 100644 --- a/ios/Classes/PosthogFlutterPlugin.swift +++ b/ios/Classes/PosthogFlutterPlugin.swift @@ -1,4 +1,4 @@ -@_spi(Experimental) import PostHog +import PostHog #if os(iOS) import Flutter import UIKit @@ -134,12 +134,11 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { // configure surveys if #available(iOS 15.0, *) { - if let surveys: Bool = posthogConfig["surveys"] as? Bool { - config.surveys = surveys - if surveys { - // if surveys are enabled, assign this instance as the survey delegate (we'll take over rendering) - config.surveysConfig.surveysDelegate = instance - } + let surveys: Bool = posthogConfig["surveys"] as? Bool ?? false + config.surveys = surveys + if surveys { + // if surveys are enabled, assign this instance as the survey delegate (we'll take over rendering) + config.surveysConfig.surveysDelegate = instance } } #endif diff --git a/ios/posthog_flutter.podspec b/ios/posthog_flutter.podspec index 9212fb25..557d432e 100644 --- a/ios/posthog_flutter.podspec +++ b/ios/posthog_flutter.podspec @@ -21,8 +21,8 @@ Postog flutter plugin s.ios.dependency 'Flutter' s.osx.dependency 'FlutterMacOS' - # ~> Version 3.30.1 up to, but not including, 4.0.0 - s.dependency 'PostHog', '>= 3.30.1', '< 4.0.0' + # ~> Version 3.31.0 up to, but not including, 4.0.0 + s.dependency 'PostHog', '>= 3.31.0', '< 4.0.0' s.ios.deployment_target = '13.0' # PH iOS SDK 3.0.0 requires >= 10.15 From 0d908b2ebbd8540a17ecf07fb67f7e40af22065c Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 29 Aug 2025 17:53:11 +0300 Subject: [PATCH 15/34] Update version --- android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt | 2 +- ios/Classes/PostHogFlutterVersion.swift | 2 +- pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt index 12598f2c..d80044d2 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt @@ -1,3 +1,3 @@ package com.posthog.flutter -internal val postHogVersion = "5.4.0" +internal val postHogVersion = "5.4.1" diff --git a/ios/Classes/PostHogFlutterVersion.swift b/ios/Classes/PostHogFlutterVersion.swift index 6c9603ce..b63bdd6e 100644 --- a/ios/Classes/PostHogFlutterVersion.swift +++ b/ios/Classes/PostHogFlutterVersion.swift @@ -8,4 +8,4 @@ import Foundation // This property is internal only -let postHogFlutterVersion = "5.4.0" +let postHogFlutterVersion = "5.4.1" diff --git a/pubspec.yaml b/pubspec.yaml index 65be001e..6110e79c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: posthog_flutter description: Flutter implementation of PostHog client for iOS, Android and Web -version: 5.4.0 +version: 5.4.1 homepage: https://www.posthog.com repository: https://github.com/posthog/posthog-flutter issue_tracker: https://github.com/posthog/posthog-flutter/issues From a55f5f4c083b63e0e575b2c169e8a2a8cc73a1b7 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Wed, 3 Sep 2025 09:50:55 +0200 Subject: [PATCH 16/34] chore: mask TextField widgets automatically if obscureText is enabled --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94092572..1a827b31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- chore: mask TextField widgets automatically if obscureText is enabled (([#202](https://github.com/PostHog/posthog-flutter/pull/202))) + ## 5.4.1 - chore: update posthog-ios dependency to min. 3.31.0 (([#202](https://github.com/PostHog/posthog-flutter/pull/202))) From 2a23c84d1d59d5ef200e279b41ac91a843180bd7 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:16:03 +0200 Subject: [PATCH 17/34] chore: mask TextField widgets automatically if obscureText is enabled (#204) --- CHANGELOG.md | 2 +- .../xcshareddata/xcschemes/Runner.xcscheme | 2 ++ example/lib/main.dart | 6 +++--- lib/src/replay/element_parsers/element_data.dart | 5 +++++ .../element_parsers/element_object_parser.dart | 14 +++++++++++++- 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a827b31..0f1b407a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## Next -- chore: mask TextField widgets automatically if obscureText is enabled (([#202](https://github.com/PostHog/posthog-flutter/pull/202))) +- chore: mask TextField widgets automatically if obscureText is enabled (([#204](https://github.com/PostHog/posthog-flutter/pull/204))) ## 5.4.1 diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 15cada48..e3773d42 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> with WidgetsBindingObserver { }, ), const SizedBox(height: 20), - const PostHogMaskWidget( - child: TextField( + const TextField( decoration: InputDecoration( labelText: 'Sensitive Text Input', hintText: 'Enter sensitive data', border: OutlineInputBorder(), ), - )), + obscureText: true, + ), const SizedBox(height: 20), PostHogMaskWidget( child: Image.asset( diff --git a/lib/src/replay/element_parsers/element_data.dart b/lib/src/replay/element_parsers/element_data.dart index 54852340..88ab1b6c 100644 --- a/lib/src/replay/element_parsers/element_data.dart +++ b/lib/src/replay/element_parsers/element_data.dart @@ -49,6 +49,11 @@ class ElementData { if (!rectList.contains(element.rect)) { if (element.widget is PostHogMaskWidget) { rectList.add(element.rect); + } else if (element.widget is TextField) { + final textField = element.widget as TextField; + if (textField.obscureText) { + rectList.add(element.rect); + } } } diff --git a/lib/src/replay/element_parsers/element_object_parser.dart b/lib/src/replay/element_parsers/element_object_parser.dart index 31103879..16ee7412 100644 --- a/lib/src/replay/element_parsers/element_object_parser.dart +++ b/lib/src/replay/element_parsers/element_object_parser.dart @@ -1,4 +1,4 @@ -import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; import 'package:posthog_flutter/src/replay/element_parsers/element_parser.dart'; @@ -30,6 +30,18 @@ class ElementObjectParser { } } + if (element.widget is TextField) { + final textField = element.widget as TextField; + if (textField.obscureText) { + final elementData = _elementParser.relate(element, activeElementData); + + if (elementData != null) { + activeElementData.addChildren(elementData); + return elementData; + } + } + } + if (element.renderObject is RenderImage) { final dataType = element.renderObject.runtimeType.toString(); From 3e1897613c93004fa6b1dcbfa4a1e3f04389360f Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Wed, 3 Sep 2025 10:17:00 +0200 Subject: [PATCH 18/34] changelog 5.4.2 --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f1b407a..9fc0db25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## Next -- chore: mask TextField widgets automatically if obscureText is enabled (([#204](https://github.com/PostHog/posthog-flutter/pull/204))) +## 5.4.2 + +- fix: mask TextField widgets automatically if obscureText is enabled (([#204](https://github.com/PostHog/posthog-flutter/pull/204))) ## 5.4.1 From bf7a0f689dd71199eef1ccd0a2815c7824a517af Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Wed, 3 Sep 2025 10:17:11 +0200 Subject: [PATCH 19/34] Update version --- android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt | 2 +- ios/Classes/PostHogFlutterVersion.swift | 2 +- pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt index d80044d2..746e134b 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt @@ -1,3 +1,3 @@ package com.posthog.flutter -internal val postHogVersion = "5.4.1" +internal val postHogVersion = "5.4.2" diff --git a/ios/Classes/PostHogFlutterVersion.swift b/ios/Classes/PostHogFlutterVersion.swift index b63bdd6e..32da6fa5 100644 --- a/ios/Classes/PostHogFlutterVersion.swift +++ b/ios/Classes/PostHogFlutterVersion.swift @@ -8,4 +8,4 @@ import Foundation // This property is internal only -let postHogFlutterVersion = "5.4.1" +let postHogFlutterVersion = "5.4.2" diff --git a/pubspec.yaml b/pubspec.yaml index 6110e79c..f26db5dc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: posthog_flutter description: Flutter implementation of PostHog client for iOS, Android and Web -version: 5.4.1 +version: 5.4.2 homepage: https://www.posthog.com repository: https://github.com/posthog/posthog-flutter issue_tracker: https://github.com/posthog/posthog-flutter/issues From 31fc43d1994c1fa8e8b5776980ab246470d23f8e Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Wed, 3 Sep 2025 10:20:32 +0200 Subject: [PATCH 20/34] fix pr id markdown --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc0db25..042f0850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,11 @@ ## 5.4.2 -- fix: mask TextField widgets automatically if obscureText is enabled (([#204](https://github.com/PostHog/posthog-flutter/pull/204))) +- fix: mask TextField widgets automatically if obscureText is enabled ([#204](https://github.com/PostHog/posthog-flutter/pull/204)) ## 5.4.1 -- chore: update posthog-ios dependency to min. 3.31.0 (([#202](https://github.com/PostHog/posthog-flutter/pull/202))) +- chore: update posthog-ios dependency to min. 3.31.0 ([#202](https://github.com/PostHog/posthog-flutter/pull/202)) ## 5.4.0 From 3735d47857a31682a928ea84a63e4f233ce9290b Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> Date: Tue, 9 Sep 2025 19:31:09 +0200 Subject: [PATCH 21/34] fix: Android back button wasn't cleaning up the Survey resources (#205) --- CHANGELOG.md | 2 + example/lib/main.dart | 6 +- .../surveys/widgets/survey_bottom_sheet.dart | 110 ++++++++++-------- 3 files changed, 66 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 042f0850..9aa52ea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- fix: Android back button wasn't cleaning up the Survey resources ([#205](https://github.com/PostHog/posthog-flutter/pull/205)) + ## 5.4.2 - fix: mask TextField widgets automatically if obscureText is enabled ([#204](https://github.com/PostHog/posthog-flutter/pull/204)) diff --git a/example/lib/main.dart b/example/lib/main.dart index ecce7bb3..670bda37 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -127,8 +127,10 @@ class InitialScreenState extends State { style: TextStyle(fontWeight: FontWeight.bold), ), ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + Wrap( + alignment: WrapAlignment.spaceEvenly, + spacing: 8.0, + runSpacing: 8.0, children: [ ElevatedButton( style: ElevatedButton.styleFrom( diff --git a/lib/src/surveys/widgets/survey_bottom_sheet.dart b/lib/src/surveys/widgets/survey_bottom_sheet.dart index 8653e40f..024333d0 100644 --- a/lib/src/surveys/widgets/survey_bottom_sheet.dart +++ b/lib/src/surveys/widgets/survey_bottom_sheet.dart @@ -171,60 +171,70 @@ class _SurveyBottomSheetState extends State { Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); - return Container( - decoration: BoxDecoration( - color: widget.appearance.backgroundColor ?? Colors.white, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - ), - child: SafeArea( - child: Padding( - padding: EdgeInsets.only( - bottom: mediaQuery.viewInsets.bottom, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header - Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - icon: const Icon(Icons.close), - onPressed: () => _handleClose(), - ), - ], + return PopScope( + canPop: false, + // TODO: replace with onPopInvokedWithResult once set bump the min Flutter version to 3.24 + // ignore: deprecated_member_use + onPopInvoked: (didPop) { + if (!didPop) { + _handleClose(); + } + }, + child: Container( + decoration: BoxDecoration( + color: widget.appearance.backgroundColor ?? Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: SafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: mediaQuery.viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () => _handleClose(), + ), + ], + ), ), - ), - // Content - Flexible( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!_isCompleted) - _buildQuestion(context) - else - ConfirmationMessage( - onClose: _handleClose, - appearance: widget.appearance, - thankYouMessageDescriptionContentType: widget - .survey - .appearance - ?.thankYouMessageDescriptionContentType ?? - PostHogDisplaySurveyTextContentType.text, - ), - ], + // Content + Flexible( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!_isCompleted) + _buildQuestion(context) + else + ConfirmationMessage( + onClose: _handleClose, + appearance: widget.appearance, + thankYouMessageDescriptionContentType: widget + .survey + .appearance + ?.thankYouMessageDescriptionContentType ?? + PostHogDisplaySurveyTextContentType.text, + ), + ], + ), ), ), ), - ), - ], + ], + ), ), ), ), From 45944ae8f133e178eeef070fb5c4030a3d18d48c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 9 Sep 2025 19:32:00 +0200 Subject: [PATCH 22/34] Update version --- CHANGELOG.md | 2 ++ android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt | 2 +- ios/Classes/PostHogFlutterVersion.swift | 2 +- pubspec.yaml | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aa52ea9..f1e3db14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +## 5.4.3 + - fix: Android back button wasn't cleaning up the Survey resources ([#205](https://github.com/PostHog/posthog-flutter/pull/205)) ## 5.4.2 diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt index 746e134b..72d6e308 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt @@ -1,3 +1,3 @@ package com.posthog.flutter -internal val postHogVersion = "5.4.2" +internal val postHogVersion = "5.4.3" diff --git a/ios/Classes/PostHogFlutterVersion.swift b/ios/Classes/PostHogFlutterVersion.swift index 32da6fa5..69380b31 100644 --- a/ios/Classes/PostHogFlutterVersion.swift +++ b/ios/Classes/PostHogFlutterVersion.swift @@ -8,4 +8,4 @@ import Foundation // This property is internal only -let postHogFlutterVersion = "5.4.2" +let postHogFlutterVersion = "5.4.3" diff --git a/pubspec.yaml b/pubspec.yaml index f26db5dc..03a00d6e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: posthog_flutter description: Flutter implementation of PostHog client for iOS, Android and Web -version: 5.4.2 +version: 5.4.3 homepage: https://www.posthog.com repository: https://github.com/posthog/posthog-flutter issue_tracker: https://github.com/posthog/posthog-flutter/issues From b8eb0c1553465f43a1949605f8067573f50e4f26 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> Date: Fri, 12 Sep 2025 08:39:15 +0200 Subject: [PATCH 23/34] chore: use flutter compile sdk version (#207) --- CHANGELOG.md | 2 ++ android/build.gradle | 2 +- example/android/app/build.gradle | 3 +-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1e3db14..e832c560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- chore: Android plugin sets compileSdkVersion to flutter.compileSdkVersion instead of hardcoded ([#207](https://github.com/PostHog/posthog-flutter/pull/207)) + ## 5.4.3 - fix: Android back button wasn't cleaning up the Survey resources ([#205](https://github.com/PostHog/posthog-flutter/pull/205)) diff --git a/android/build.gradle b/android/build.gradle index 3195ad97..be287150 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -29,7 +29,7 @@ android { namespace 'com.posthog.flutter' } - compileSdkVersion 33 + compileSdkVersion flutter.compileSdkVersion compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index a7fe5185..3b7dcd39 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -24,8 +24,7 @@ if (flutterVersionName == null) { android { namespace "com.example.flutter" - // use flutter.compileSdkVersion once https://github.com/flutter/flutter/issues/153893 is fixed - compileSdkVersion 34 + compileSdkVersion flutter.compileSdkVersion ndkVersion flutter.ndkVersion compileOptions { From 4c0800dd4bbec1ef261e55407ff94d486137ea6d Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 12 Sep 2025 08:40:55 +0200 Subject: [PATCH 24/34] Update version --- CHANGELOG.md | 2 ++ android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt | 2 +- ios/Classes/PostHogFlutterVersion.swift | 2 +- pubspec.yaml | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e832c560..6126fc26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +## 5.5.0 + - chore: Android plugin sets compileSdkVersion to flutter.compileSdkVersion instead of hardcoded ([#207](https://github.com/PostHog/posthog-flutter/pull/207)) ## 5.4.3 diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt index 72d6e308..c3366969 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt @@ -1,3 +1,3 @@ package com.posthog.flutter -internal val postHogVersion = "5.4.3" +internal val postHogVersion = "5.5.0" diff --git a/ios/Classes/PostHogFlutterVersion.swift b/ios/Classes/PostHogFlutterVersion.swift index 69380b31..c40e0fb3 100644 --- a/ios/Classes/PostHogFlutterVersion.swift +++ b/ios/Classes/PostHogFlutterVersion.swift @@ -8,4 +8,4 @@ import Foundation // This property is internal only -let postHogFlutterVersion = "5.4.3" +let postHogFlutterVersion = "5.5.0" diff --git a/pubspec.yaml b/pubspec.yaml index 03a00d6e..f9d901c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: posthog_flutter description: Flutter implementation of PostHog client for iOS, Android and Web -version: 5.4.3 +version: 5.5.0 homepage: https://www.posthog.com repository: https://github.com/posthog/posthog-flutter issue_tracker: https://github.com/posthog/posthog-flutter/issues From 62a5611e0d1fb2ec68c3cf0dfa17c7c2c2e263a0 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> Date: Mon, 6 Oct 2025 13:12:43 +0200 Subject: [PATCH 25/34] feat: surveys use the new response question id format (#210) --- CHANGELOG.md | 2 ++ android/build.gradle | 4 ++-- .../kotlin/com/posthog/flutter/PostHogDisplaySurveyExt.kt | 1 + ios/Classes/PostHogDisplaySurvey+Dict.swift | 1 + ios/posthog_flutter.podspec | 4 ++-- lib/src/surveys/models/posthog_display_choice_question.dart | 1 + lib/src/surveys/models/posthog_display_link_question.dart | 1 + lib/src/surveys/models/posthog_display_open_question.dart | 1 + lib/src/surveys/models/posthog_display_rating_question.dart | 1 + lib/src/surveys/models/posthog_display_survey.dart | 5 +++++ lib/src/surveys/models/posthog_display_survey_question.dart | 2 ++ 11 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6126fc26..86e068d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- feat: surveys use the new response question id format ([#210](https://github.com/PostHog/posthog-flutter/pull/210)) + ## 5.5.0 - chore: Android plugin sets compileSdkVersion to flutter.compileSdkVersion instead of hardcoded ([#207](https://github.com/PostHog/posthog-flutter/pull/207)) diff --git a/android/build.gradle b/android/build.gradle index be287150..2c8f9e7d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -54,8 +54,8 @@ android { dependencies { testImplementation 'org.jetbrains.kotlin:kotlin-test' testImplementation 'org.mockito:mockito-core:5.0.0' - // + Version 3.21.0 and the versions up to 4.0.0, not including 4.0.0 and higher - implementation 'com.posthog:posthog-android:[3.21.0,4.0.0]' + // + Version 3.23.0 and the versions up to 4.0.0, not including 4.0.0 and higher + implementation 'com.posthog:posthog-android:[3.23.0,4.0.0]' } testOptions { diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogDisplaySurveyExt.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogDisplaySurveyExt.kt index a5604849..d8a64350 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PostHogDisplaySurveyExt.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogDisplaySurveyExt.kt @@ -20,6 +20,7 @@ fun PostHogDisplaySurvey.toMap(): Map { mutableMapOf( "question" to question.question, "isOptional" to question.isOptional, + "id" to question.id, ) questionMap["questionDescription"] = question.questionDescription diff --git a/ios/Classes/PostHogDisplaySurvey+Dict.swift b/ios/Classes/PostHogDisplaySurvey+Dict.swift index 3ad5d8e9..6c2e961e 100644 --- a/ios/Classes/PostHogDisplaySurvey+Dict.swift +++ b/ios/Classes/PostHogDisplaySurvey+Dict.swift @@ -13,6 +13,7 @@ var questionDict: [String: Any] = [ "question": question.question, "isOptional": question.isOptional, + "id": question.id, ] if let desc = question.questionDescription { diff --git a/ios/posthog_flutter.podspec b/ios/posthog_flutter.podspec index 557d432e..8771ce0f 100644 --- a/ios/posthog_flutter.podspec +++ b/ios/posthog_flutter.podspec @@ -21,8 +21,8 @@ Postog flutter plugin s.ios.dependency 'Flutter' s.osx.dependency 'FlutterMacOS' - # ~> Version 3.31.0 up to, but not including, 4.0.0 - s.dependency 'PostHog', '>= 3.31.0', '< 4.0.0' + # ~> Version 3.32.0 up to, but not including, 4.0.0 + s.dependency 'PostHog', '>= 3.32.0', '< 4.0.0' s.ios.deployment_target = '13.0' # PH iOS SDK 3.0.0 requires >= 10.15 diff --git a/lib/src/surveys/models/posthog_display_choice_question.dart b/lib/src/surveys/models/posthog_display_choice_question.dart index d60f4259..5b116d72 100644 --- a/lib/src/surveys/models/posthog_display_choice_question.dart +++ b/lib/src/surveys/models/posthog_display_choice_question.dart @@ -6,6 +6,7 @@ import 'posthog_display_survey_question.dart'; @immutable class PostHogDisplayChoiceQuestion extends PostHogDisplaySurveyQuestion { const PostHogDisplayChoiceQuestion({ + required super.id, required super.question, required this.choices, required this.isMultipleChoice, diff --git a/lib/src/surveys/models/posthog_display_link_question.dart b/lib/src/surveys/models/posthog_display_link_question.dart index e6d43b07..5658e938 100644 --- a/lib/src/surveys/models/posthog_display_link_question.dart +++ b/lib/src/surveys/models/posthog_display_link_question.dart @@ -6,6 +6,7 @@ import 'posthog_display_survey_question.dart'; @immutable class PostHogDisplayLinkQuestion extends PostHogDisplaySurveyQuestion { const PostHogDisplayLinkQuestion({ + required super.id, required super.question, required this.link, super.description, diff --git a/lib/src/surveys/models/posthog_display_open_question.dart b/lib/src/surveys/models/posthog_display_open_question.dart index a73fde4e..c8b7f07d 100644 --- a/lib/src/surveys/models/posthog_display_open_question.dart +++ b/lib/src/surveys/models/posthog_display_open_question.dart @@ -6,6 +6,7 @@ import 'posthog_display_survey_question.dart'; @immutable class PostHogDisplayOpenQuestion extends PostHogDisplaySurveyQuestion { const PostHogDisplayOpenQuestion({ + required super.id, required super.question, super.description, super.descriptionContentType, diff --git a/lib/src/surveys/models/posthog_display_rating_question.dart b/lib/src/surveys/models/posthog_display_rating_question.dart index 5be41d31..4b10c1a9 100644 --- a/lib/src/surveys/models/posthog_display_rating_question.dart +++ b/lib/src/surveys/models/posthog_display_rating_question.dart @@ -7,6 +7,7 @@ import 'posthog_display_survey_rating_type.dart'; @immutable class PostHogDisplayRatingQuestion extends PostHogDisplaySurveyQuestion { const PostHogDisplayRatingQuestion({ + required super.id, required super.question, required this.ratingType, required this.scaleLowerBound, diff --git a/lib/src/surveys/models/posthog_display_survey.dart b/lib/src/surveys/models/posthog_display_survey.dart index dda52aab..194508c2 100644 --- a/lib/src/surveys/models/posthog_display_survey.dart +++ b/lib/src/surveys/models/posthog_display_survey.dart @@ -15,6 +15,7 @@ class PostHogDisplaySurvey { // Native platform model -> Dictionary -> Dart model factory PostHogDisplaySurvey.fromDict(Map dict) { final questions = (dict['questions'] as List).map((q) { + final id = q['type'] as String? ?? ''; final type = q['type'] as String; final question = q['question'] as String; final optional = q['isOptional'] as bool; @@ -30,6 +31,7 @@ class PostHogDisplaySurvey { switch (type) { case 'link': return PostHogDisplayLinkQuestion( + id: id, question: question, link: q['link'] as String, description: questionDescription, @@ -39,6 +41,7 @@ class PostHogDisplaySurvey { ); case 'rating': return PostHogDisplayRatingQuestion( + id: id, question: question, ratingType: PostHogDisplaySurveyRatingType.fromInt(q['ratingType'] as int), @@ -54,6 +57,7 @@ class PostHogDisplaySurvey { case 'multiple_choice': case 'single_choice': return PostHogDisplayChoiceQuestion( + id: id, question: question, choices: (q['choices'] as List).cast(), isMultipleChoice: type == 'multiple_choice', @@ -67,6 +71,7 @@ class PostHogDisplaySurvey { case 'open': default: return PostHogDisplayOpenQuestion( + id: id, question: question, description: questionDescription, descriptionContentType: questionDescriptionContentType, diff --git a/lib/src/surveys/models/posthog_display_survey_question.dart b/lib/src/surveys/models/posthog_display_survey_question.dart index 727335ab..1c97b86a 100644 --- a/lib/src/surveys/models/posthog_display_survey_question.dart +++ b/lib/src/surveys/models/posthog_display_survey_question.dart @@ -6,6 +6,7 @@ import 'posthog_display_survey_text_content_type.dart'; @immutable abstract class PostHogDisplaySurveyQuestion { const PostHogDisplaySurveyQuestion({ + required this.id, required this.type, required this.question, this.description, @@ -14,6 +15,7 @@ abstract class PostHogDisplaySurveyQuestion { this.buttonText, }); + final String id; final PostHogSurveyQuestionType type; final String question; final String? description; From eae110aafeeb1c7694e9ae361953b303c2e70d46 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 6 Oct 2025 13:13:21 +0200 Subject: [PATCH 26/34] changelog 5.6.0 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86e068d7..802603a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +## 5.6.0 + - feat: surveys use the new response question id format ([#210](https://github.com/PostHog/posthog-flutter/pull/210)) ## 5.5.0 From b339e95e29c2f152f03a8581c101a98da6f175f4 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 6 Oct 2025 13:13:35 +0200 Subject: [PATCH 27/34] Update version --- android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt | 2 +- ios/Classes/PostHogFlutterVersion.swift | 2 +- pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt index c3366969..32b3a5b4 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt @@ -1,3 +1,3 @@ package com.posthog.flutter -internal val postHogVersion = "5.5.0" +internal val postHogVersion = "5.6.0" diff --git a/ios/Classes/PostHogFlutterVersion.swift b/ios/Classes/PostHogFlutterVersion.swift index c40e0fb3..ac372ee0 100644 --- a/ios/Classes/PostHogFlutterVersion.swift +++ b/ios/Classes/PostHogFlutterVersion.swift @@ -8,4 +8,4 @@ import Foundation // This property is internal only -let postHogFlutterVersion = "5.5.0" +let postHogFlutterVersion = "5.6.0" diff --git a/pubspec.yaml b/pubspec.yaml index f9d901c1..8a0ff19a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: posthog_flutter description: Flutter implementation of PostHog client for iOS, Android and Web -version: 5.5.0 +version: 5.6.0 homepage: https://www.posthog.com repository: https://github.com/posthog/posthog-flutter issue_tracker: https://github.com/posthog/posthog-flutter/issues From 88fddaeee25c2f2fb214b0e6ea336be1d4179263 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 31 Oct 2025 16:00:08 +0200 Subject: [PATCH 28/34] feat: add manual error capture (#212) * feat: add public interface * feat: add config and exception processor * feat: native plugins * chore: update sample app * feat: add unit tests * feat: handle primitives * chore: update changelog * fix: package and module * fix: format * fix: use Object vs dynamic * chore: rename folder * fix: make stackTrace optional * fix: clean generated stack trace * feat: add unit tests * fix: make function optional * fix: drop primitive check * fix: skip module * fix: make thread_id optional * fix: update config * fix: error type * fix: doc * fix: handle empty stack trace * fix: allow overwriting exception properties * fix: normalize props * chore: bump min dart and flutter version * fix: generate event timestamp flutter side * fix: remove handled from public api * fix: platform call arguments * fix: example app * fix: do not try to parse exception package * fix: normalize props * fix: normalize sets * fix: remove handled from public interface * fix: web handler * fix: replace hof with direct iteration * fix: * feat: add web support * chore: add config comment * feat: add async gap franes * Revert "feat: add web support" This reverts commit 6c0529ccfaa634514a12cfeaa245568ddfad41c1. --- CHANGELOG.md | 8 + Makefile | 7 +- android/build.gradle | 4 +- .../posthog/flutter/PosthogFlutterPlugin.kt | 32 ++ example/lib/main.dart | 81 +++ example/pubspec.yaml | 2 +- ios/Classes/PosthogFlutterPlugin.swift | 21 + .../dart_exception_processor.dart | 291 +++++++++++ .../utils/_io_isolate_utils.dart | 15 + .../utils/_web_isolate_utils.dart | 7 + .../error_tracking/utils/isolate_utils.dart | 10 + lib/src/posthog.dart | 12 + lib/src/posthog_config.dart | 57 +++ lib/src/posthog_flutter_io.dart | 70 ++- .../posthog_flutter_platform_interface.dart | 7 + lib/src/posthog_flutter_web_handler.dart | 3 + lib/src/utils/property_normalizer.dart | 54 ++ pubspec.yaml | 5 +- test/dart_exception_processor_test.dart | 464 ++++++++++++++++++ test/property_normalizer_test.dart | 107 ++++ 20 files changed, 1245 insertions(+), 12 deletions(-) create mode 100644 lib/src/error_tracking/dart_exception_processor.dart create mode 100644 lib/src/error_tracking/utils/_io_isolate_utils.dart create mode 100644 lib/src/error_tracking/utils/_web_isolate_utils.dart create mode 100644 lib/src/error_tracking/utils/isolate_utils.dart create mode 100644 lib/src/utils/property_normalizer.dart create mode 100644 test/dart_exception_processor_test.dart create mode 100644 test/property_normalizer_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 802603a7..3894822a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## Next +- feat: add manual error capture ([#212](https://github.com/PostHog/posthog-flutter/pull/212)) + - **Note**: The following features are not yet supported: + - Automatic exception capture + - De-obfuscating stacktraces from obfuscated builds ([--obfuscate](https://docs.flutter.dev/deployment/obfuscate) and [--split-debug-info](https://docs.flutter.dev/deployment/obfuscate)) + - [Source code context](/docs/error-tracking/stack-traces) associated with an exception + - Flutter web support + - **BREAKING**: Minimum Dart SDK version bumped to 3.4.0 and Flutter to 3.22.0 (required for `stack_trace` dependency compatibility) + ## 5.6.0 - feat: surveys use the new response question id format ([#210](https://github.com/PostHog/posthog-flutter/pull/210)) diff --git a/Makefile b/Makefile index 6144c345..5a0475c8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ -.PHONY: formatKotlin formatSwift formatDart checkDart installLinters +.PHONY: format formatKotlin formatSwift formatDart checkDart installLinters test + +format: formatSwift formatKotlin formatDart installLinters: brew install ktlint @@ -19,3 +21,6 @@ checkFormatDart: analyzeDart: dart analyze . + +test: + flutter test -r expanded diff --git a/android/build.gradle b/android/build.gradle index 2c8f9e7d..97084418 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -54,8 +54,8 @@ android { dependencies { testImplementation 'org.jetbrains.kotlin:kotlin-test' testImplementation 'org.mockito:mockito-core:5.0.0' - // + Version 3.23.0 and the versions up to 4.0.0, not including 4.0.0 and higher - implementation 'com.posthog:posthog-android:[3.23.0,4.0.0]' + // + Version 3.25.0 and the versions up to 4.0.0, not including 4.0.0 and higher + implementation 'com.posthog:posthog-android:[3.25.0,4.0.0]' } testOptions { diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 380e8d5a..80217cb3 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -19,6 +19,7 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result +import java.util.Date /** PosthogFlutterPlugin */ class PosthogFlutterPlugin : @@ -156,6 +157,9 @@ class PosthogFlutterPlugin : "flush" -> { flush(result) } + "captureException" -> { + captureException(call, result) + } "close" -> { close(result) } @@ -532,6 +536,34 @@ class PosthogFlutterPlugin : } } + private fun captureException( + call: MethodCall, + result: Result, + ) { + try { + val arguments = + call.arguments as? Map ?: run { + result.error("INVALID_ARGUMENTS", "Invalid arguments for captureException", null) + return + } + + val properties = arguments["properties"] as? Map + val timestampMs = arguments["timestamp"] as? Long + + // Extract timestamp from Flutter + val timestamp: Date? = + timestampMs?.let { + // timestampMs already in UTC milliseconds epoch + Date(timestampMs) + } + + PostHog.capture("\$exception", properties = properties, timestamp = timestamp) + result.success(null) + } catch (e: Throwable) { + result.error("CAPTURE_EXCEPTION_ERROR", "Failed to capture exception: ${e.message}", null) + } + } + private fun close(result: Result) { try { PostHog.close() diff --git a/example/lib/main.dart b/example/lib/main.dart index 670bda37..686374a3 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -240,6 +240,66 @@ class InitialScreenState extends State { child: Text("distinctId"), )), const Divider(), + const Padding( + padding: EdgeInsets.all(8.0), + child: Text( + "Error Tracking", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ElevatedButton( + onPressed: () async { + try { + // Simulate an exception in main isolate + // throw 'a custom error string'; + // throw 333; + throw CustomException( + 'This is a custom exception with additional context', + code: 'DEMO_ERROR_001', + additionalData: { + 'user_action': 'button_press', + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'feature_enabled': true, + }, + ); + } catch (e, stack) { + await Posthog().captureException( + error: e, + stackTrace: stack, + properties: { + 'test_type': 'main_isolate_exception', + 'button_pressed': 'capture_exception_main', + 'exception_category': 'custom', + }, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Main isolate exception captured successfully! Check PostHog.'), + backgroundColor: Colors.green, + duration: Duration(seconds: 3), + ), + ); + } + } + }, + child: const Text("Capture Exception"), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + ), + onPressed: () async { + await Posthog().captureException( + error: 'No Stack Trace Error', + properties: {'test_type': 'no_stack_trace'}, + ); + }, + child: const Text("Capture Exception (Missing Stack)"), + ), + const Divider(), const Padding( padding: EdgeInsets.all(8.0), child: Text( @@ -391,3 +451,24 @@ class ThirdRoute extends StatelessWidget { ); } } + +/// Custom exception class for demonstration purposes +class CustomException implements Exception { + final String message; + final String? code; + final Map? additionalData; + + const CustomException( + this.message, { + this.code, + this.additionalData, + }); + + @override + String toString() { + if (code != null) { + return 'CustomException($code): $message $additionalData'; + } + return 'CustomException: $message $additionalData'; + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 99ad242f..e8a8abfa 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -6,7 +6,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: '>=2.18.0 <4.0.0' + sdk: '>=3.4.0 <4.0.0' flutter: '>=3.3.0' # Dependencies specify other packages that your package needs in order to work. diff --git a/ios/Classes/PosthogFlutterPlugin.swift b/ios/Classes/PosthogFlutterPlugin.swift index 9bac3192..95e840c0 100644 --- a/ios/Classes/PosthogFlutterPlugin.swift +++ b/ios/Classes/PosthogFlutterPlugin.swift @@ -195,6 +195,8 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { unregister(call, result: result) case "flush": flush(result) + case "captureException": + captureException(call, result: result) case "close": close(result) case "sendMetaEvent": @@ -677,6 +679,25 @@ extension PosthogFlutterPlugin { result(nil) } + private func captureException(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let arguments = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments for captureException", details: nil)) + return + } + + let properties = arguments["properties"] as? [String: Any] + + // Extract timestamp from Flutter and convert to Date + var timestamp: Date? = nil + if let timestampMs = arguments["timestamp"] as? Int64 { + timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000.0) + } + + // Use capture method with timestamp to ensure Flutter timestamp is used + PostHogSDK.shared.capture("$exception", properties: properties, timestamp: timestamp) + result(nil) + } + private func close(_ result: @escaping FlutterResult) { PostHogSDK.shared.close() result(nil) diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart new file mode 100644 index 00000000..4782d3cc --- /dev/null +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -0,0 +1,291 @@ +import 'package:stack_trace/stack_trace.dart'; +import 'utils/isolate_utils.dart' as isolate_utils; + +class DartExceptionProcessor { + /// Converts Dart error/exception and stack trace to PostHog exception format + static Map processException({ + required Object error, + StackTrace? stackTrace, + Map? properties, + List? inAppIncludes, + List? inAppExcludes, + bool inAppByDefault = true, + StackTrace Function()? stackTraceProvider, //for testing + }) { + StackTrace? effectiveStackTrace = stackTrace; + bool isGeneratedStackTrace = false; + + // If it's an Error, try to use its built-in stackTrace + if (error is Error) { + effectiveStackTrace ??= error.stackTrace; + } + + // If still null or empty, get current stack trace + if (effectiveStackTrace == null || + effectiveStackTrace == StackTrace.empty) { + effectiveStackTrace = stackTraceProvider?.call() ?? StackTrace.current; + isGeneratedStackTrace = true; // Flag to remove top PostHog frames + } + + // Check if we still have an empty stack trace + final hasValidStackTrace = effectiveStackTrace != StackTrace.empty; + + // Process single exception for now + final frames = hasValidStackTrace + ? _parseStackTrace( + effectiveStackTrace, + inAppIncludes: inAppIncludes, + inAppExcludes: inAppExcludes, + inAppByDefault: inAppByDefault, + removeTopPostHogFrames: isGeneratedStackTrace, + ) + : >[]; + + final errorType = _getExceptionType(error); + + // Mark exception as synthetic if: + // - runtimeType.toString() returned empty/null (fallback to 'Error' type) + // - Stack trace was generated by PostHog (not from original exception) + // - No valid stack trace is available + final isSynthetic = + errorType == null || isGeneratedStackTrace || !hasValidStackTrace; + + final exceptionData = { + 'type': errorType ?? 'Error', + 'mechanism': { + 'handled': true, // always true for now + 'synthetic': isSynthetic, + 'type': 'generic', + } + }; + + // Add exception message, if available + final errorMessage = error.toString(); + if (errorMessage.isNotEmpty) { + exceptionData['value'] = errorMessage; + } + + // Add stacktrace, if any frames are available + if (frames.isNotEmpty) { + exceptionData['stacktrace'] = { + 'frames': frames, + 'type': 'raw', + }; + } + + // Add thread ID, if available + final threadId = _getCurrentThreadId(); + if (threadId != null) { + exceptionData['thread_id'] = threadId; + } + + // Final result, merging system properties with user properties (user properties take precedence) + final result = { + '\$exception_level': 'error', // Never crashes, so always error + '\$exception_list': [exceptionData], + if (properties != null) ...properties, + }; + + return result; + } + + /// Determines if a stack frame belongs to PostHog SDK (just check package for now) + static bool _isPostHogFrame(Frame frame) { + return frame.package == 'posthog_flutter'; + } + + /// Asynchronous gap frame for separating async traces + static const _asynchronousGapFrame = { + 'platform': 'dart', + 'abs_path': '', + 'in_app': false, + 'synthetic': true + }; + + /// Parses stack trace into PostHog format + /// + /// Approach inspired by Sentry's stack trace factory implementation: + /// https://github.com/getsentry/sentry-dart/blob/a69a51fd1695dd93024be80a50ad05dd990b2b82/packages/dart/lib/src/sentry_stack_trace_factory.dart#L29-L53 + static List> _parseStackTrace( + StackTrace stackTrace, { + List? inAppIncludes, + List? inAppExcludes, + bool inAppByDefault = true, + bool removeTopPostHogFrames = false, + }) { + final chain = Chain.forTrace(stackTrace); + final frames = >[]; + + for (final (index, trace) in chain.traces.indexed) { + bool skipNextPostHogFrame = removeTopPostHogFrames; + + for (final frame in trace.frames) { + // Skip top PostHog frames? + if (skipNextPostHogFrame) { + if (_isPostHogFrame(frame)) { + continue; + } + skipNextPostHogFrame = false; + } + + final processedFrame = _convertFrameToPostHog( + frame, + inAppIncludes: inAppIncludes, + inAppExcludes: inAppExcludes, + inAppByDefault: inAppByDefault, + ); + if (processedFrame != null) { + frames.add(processedFrame); + } + } + + // Add asynchronous gap frame between traces (skipping last trace) + if (index < chain.traces.length - 1) { + frames.add(_asynchronousGapFrame); + } + } + + return frames; + } + + /// Converts a Frame from stack_trace package to PostHog format + static Map? _convertFrameToPostHog( + Frame frame, { + List? inAppIncludes, + List? inAppExcludes, + bool inAppByDefault = true, + }) { + final frameData = { + 'platform': 'dart', + 'abs_path': _extractAbsolutePath(frame), + 'in_app': _isInAppFrame( + frame, + inAppIncludes: inAppIncludes, + inAppExcludes: inAppExcludes, + inAppByDefault: inAppByDefault, + ), + }; + + // add package, if available + final package = _extractPackage(frame); + if (package != null && package.isNotEmpty) { + frameData['package'] = package; + } + + // add function, if available + final member = frame.member; + if (member != null && member.isNotEmpty) { + frameData['function'] = member; + } + + // Add filename, if available + final fileName = _extractFileName(frame); + if (fileName != null && fileName.isNotEmpty) { + frameData['filename'] = fileName; + } + + // Add line number, if available + final line = frame.line; + if (line != null && line >= 0) { + frameData['lineno'] = line; + } + + // Add column number, if available + final column = frame.column; + if (column != null && column >= 0) { + frameData['colno'] = column; + } + + return frameData; + } + + /// Determines if a frame is considered in-app + static bool _isInAppFrame( + Frame frame, { + List? inAppIncludes, + List? inAppExcludes, + bool inAppByDefault = true, + }) { + final scheme = frame.uri.scheme; + + if (scheme.isEmpty) { + // Early bail out for unknown schemes + return inAppByDefault; + } + + final package = frame.package; + if (package != null) { + // 1. Check inAppIncludes first (highest priority) + if (inAppIncludes != null && inAppIncludes.contains(package)) { + return true; + } + + // 2. Check inAppExcludes second + if (inAppExcludes != null && inAppExcludes.contains(package)) { + return false; + } + } + + // 3. Hardcoded exclusions + if (frame.isCore) { + // dart: packages + return false; + } + + if (frame.package == 'flutter') { + // flutter package + return false; + } + + // 4. Default fallback + return inAppByDefault; + } + + static String? _extractPackage(Frame frame) { + return frame.package; + } + + static String? _extractFileName(Frame frame) { + return frame.uri.pathSegments.isNotEmpty + ? frame.uri.pathSegments.last + : null; + } + + static String _extractAbsolutePath(Frame frame) { + // For privacy, only return filename for local file paths + if (frame.uri.scheme != 'dart' && + frame.uri.scheme != 'package' && + frame.uri.pathSegments.isNotEmpty) { + return frame.uri.pathSegments.last; // Just filename for privacy + } + + // For dart: and package: URIs, full path is safe + return frame.uri.toString(); + } + + /// Gets the current thread ID using isolate-based detection + static int? _getCurrentThreadId() { + try { + // Check if we're in the root isolate (main thread) + if (isolate_utils.isRootIsolate()) { + return 'main'.hashCode; + } + + // For other isolates, use the isolate's debug name + final isolateName = isolate_utils.getIsolateName(); + if (isolateName != null && isolateName.isNotEmpty) { + return isolateName.hashCode; + } + + return null; + } catch (e) { + return null; + } + } + + static String? _getExceptionType(Object error) { + // The string is only intended for providing information to a reader while debugging. There is no guaranteed format, the string value returned for a Type instances is entirely implementation dependent. + final type = error.runtimeType.toString(); + return type.isNotEmpty ? type : null; + } +} diff --git a/lib/src/error_tracking/utils/_io_isolate_utils.dart b/lib/src/error_tracking/utils/_io_isolate_utils.dart new file mode 100644 index 00000000..d8787500 --- /dev/null +++ b/lib/src/error_tracking/utils/_io_isolate_utils.dart @@ -0,0 +1,15 @@ +import 'dart:isolate'; +import 'package:flutter/services.dart'; + +/// Gets the current isolate's debug name for IO platforms +String? getIsolateName() => Isolate.current.debugName; + +/// Determines if the current isolate is the root isolate for IO platforms +/// Uses Flutter's ServicesBinding to detect the root isolate +bool isRootIsolate() { + try { + return ServicesBinding.rootIsolateToken != null; + } catch (_) { + return true; + } +} diff --git a/lib/src/error_tracking/utils/_web_isolate_utils.dart b/lib/src/error_tracking/utils/_web_isolate_utils.dart new file mode 100644 index 00000000..e3e0797b --- /dev/null +++ b/lib/src/error_tracking/utils/_web_isolate_utils.dart @@ -0,0 +1,7 @@ +/// Gets the current isolate's debug name for web platforms +/// Web is single-threaded, so always returns 'main' +String? getIsolateName() => 'main'; + +/// Determines if the current isolate is the root isolate for web platforms +/// Web is single-threaded, so always returns true +bool isRootIsolate() => true; diff --git a/lib/src/error_tracking/utils/isolate_utils.dart b/lib/src/error_tracking/utils/isolate_utils.dart new file mode 100644 index 00000000..12c56a9d --- /dev/null +++ b/lib/src/error_tracking/utils/isolate_utils.dart @@ -0,0 +1,10 @@ +import '_io_isolate_utils.dart' + if (dart.library.js_interop) '_web_isolate_utils.dart' as platform; + +/// Gets the current isolate's debug name +/// Returns null if the name cannot be determined +String? getIsolateName() => platform.getIsolateName(); + +/// Determines if the current isolate is the root/main isolate +/// Returns true for the main isolate, false for background isolates +bool isRootIsolate() => platform.isRootIsolate(); diff --git a/lib/src/posthog.dart b/lib/src/posthog.dart index 03ad89e0..2eaec4fe 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -121,6 +121,18 @@ class Posthog { Future flush() => _posthog.flush(); + /// Captures exceptions with optional custom properties + /// + /// [error] - The error/exception to capture + /// [stackTrace] - Optional stack trace (if not provided, current stack trace will be used) + /// [properties] - Optional custom properties to attach to the exception event + Future captureException( + {required Object error, + StackTrace? stackTrace, + Map? properties}) => + _posthog.captureException( + error: error, stackTrace: stackTrace, properties: properties); + /// Closes the PostHog SDK and cleans up resources. /// /// Note: Please note that after calling close(), surveys will not be rendered until the SDK is re-initialized and the next navigation event occurs. diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index 85f31988..00ccb9a5 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -39,6 +39,9 @@ class PostHogConfig { @experimental var surveys = false; + /// Configuration for error tracking and exception capture + final errorTrackingConfig = PostHogErrorTrackingConfig(); + // TODO: missing getAnonymousId, propertiesSanitizer, captureDeepLinks // onFeatureFlags, integrations @@ -62,6 +65,7 @@ class PostHogConfig { 'sessionReplay': sessionReplay, 'dataMode': dataMode.name, 'sessionReplayConfig': sessionReplayConfig.toMap(), + 'errorTrackingConfig': errorTrackingConfig.toMap(), }; } } @@ -100,3 +104,56 @@ class PostHogSessionReplayConfig { }; } } + +class PostHogErrorTrackingConfig { + /// List of package names to be considered inApp frames for exception tracking + /// + /// inApp Example: + /// inAppIncludes = ["package:your_app", "package:your_company_utils"] + /// All exception stacktrace frames from these packages will be considered inApp + /// + /// This option takes precedence over inAppExcludes. + /// For Flutter/Dart, this typically includes: + /// - Your app's main package (e.g., "package:your_app") + /// - Any internal packages you own (e.g., "package:your_company_utils") + /// + /// Note: This config will be ignored on web builds + final inAppIncludes = []; + + /// List of package names to be excluded from inApp frames for exception tracking + /// + /// inAppExcludes Example: + /// inAppExcludes = ["package:third_party_lib", "package:analytics_package"] + /// All exception stacktrace frames from these packages will be considered external + /// + /// Note: inAppIncludes takes precedence over this setting. + /// Common packages to exclude: + /// - Third-party analytics packages + /// - External utility libraries + /// - Packages you don't control + /// + /// Note: This config will be ignored on web builds + final inAppExcludes = []; + + /// Configures whether stack trace frames are considered inApp by default + /// when the origin cannot be determined or no explicit includes/excludes match. + /// + /// - If true: Frames are inApp unless explicitly excluded (allowlist approach) + /// - If false: Frames are external unless explicitly included (denylist approach) + /// + /// Default behavior when true: + /// - Local files (no package prefix) are inApp + /// - dart and flutter packages are excluded + /// - All other packages are inApp unless in inAppExcludes + /// + /// Note: This config will be ignored on web builds + var inAppByDefault = true; + + Map toMap() { + return { + 'inAppIncludes': inAppIncludes, + 'inAppExcludes': inAppExcludes, + 'inAppByDefault': inAppByDefault, + }; + } +} diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index 45e9ea53..fb9c7360 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -9,6 +9,8 @@ import 'package:posthog_flutter/src/surveys/survey_service.dart'; import 'package:posthog_flutter/src/util/logging.dart'; import 'surveys/models/posthog_display_survey.dart' as models; import 'surveys/models/survey_callbacks.dart'; +import 'error_tracking/dart_exception_processor.dart'; +import 'utils/property_normalizer.dart'; import 'posthog_config.dart'; import 'posthog_flutter_platform_interface.dart'; @@ -22,6 +24,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// The method channel used to interact with the native platform. final _methodChannel = const MethodChannel('posthog_flutter'); + /// Stored configuration for accessing inAppIncludes and other settings + PostHogConfig? _config; + /// Native plugin calls to Flutter /// Future _handleMethodCall(MethodCall call) async { @@ -116,6 +121,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// @override Future setup(PostHogConfig config) async { + // Store config for later use in exception processing + _config = config; + if (!isSupportedPlatform()) { return; } @@ -138,11 +146,19 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { + final normalizedUserProperties = userProperties != null + ? PropertyNormalizer.normalize(userProperties) + : null; + final normalizedUserPropertiesSetOnce = userPropertiesSetOnce != null + ? PropertyNormalizer.normalize(userPropertiesSetOnce) + : null; + await _methodChannel.invokeMethod('identify', { 'userId': userId, - if (userProperties != null) 'userProperties': userProperties, - if (userPropertiesSetOnce != null) - 'userPropertiesSetOnce': userPropertiesSetOnce, + if (normalizedUserProperties != null) + 'userProperties': normalizedUserProperties, + if (normalizedUserPropertiesSetOnce != null) + 'userPropertiesSetOnce': normalizedUserPropertiesSetOnce, }); } on PlatformException catch (exception) { printIfDebug('Exeption on identify: $exception'); @@ -159,9 +175,12 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { + final normalizedProperties = + properties != null ? PropertyNormalizer.normalize(properties) : null; + await _methodChannel.invokeMethod('capture', { 'eventName': eventName, - if (properties != null) 'properties': properties, + if (normalizedProperties != null) 'properties': normalizedProperties, }); } on PlatformException catch (exception) { printIfDebug('Exeption on capture: $exception'); @@ -178,9 +197,12 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { + final normalizedProperties = + properties != null ? PropertyNormalizer.normalize(properties) : null; + await _methodChannel.invokeMethod('screen', { 'screenName': screenName, - if (properties != null) 'properties': properties, + if (normalizedProperties != null) 'properties': normalizedProperties, }); } on PlatformException catch (exception) { printIfDebug('Exeption on screen: $exception'); @@ -327,10 +349,15 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { + final normalizedGroupProperties = groupProperties != null + ? PropertyNormalizer.normalize(groupProperties) + : null; + await _methodChannel.invokeMethod('group', { 'groupType': groupType, 'groupKey': groupKey, - if (groupProperties != null) 'groupProperties': groupProperties, + if (normalizedGroupProperties != null) + 'groupProperties': normalizedGroupProperties, }); } on PlatformException catch (exception) { printIfDebug('Exeption on group: $exception'); @@ -413,6 +440,37 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } } + @override + Future captureException( + {required Object error, + StackTrace? stackTrace, + Map? properties}) async { + if (!isSupportedPlatform()) { + return; + } + + try { + final exceptionData = DartExceptionProcessor.processException( + error: error, + stackTrace: stackTrace, + properties: properties, + inAppIncludes: _config?.errorTrackingConfig.inAppIncludes, + inAppExcludes: _config?.errorTrackingConfig.inAppExcludes, + inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, + ); + +// Add timestamp from Flutter side (will be used and removed from native plugins) + final timestamp = DateTime.now().millisecondsSinceEpoch; + final normalizedData = + PropertyNormalizer.normalize(exceptionData.cast()); + + await _methodChannel.invokeMethod('captureException', + {'timestamp': timestamp, 'properties': normalizedData}); + } on PlatformException catch (exception) { + printIfDebug('Exception in captureException: $exception'); + } + } + @override Future close() async { if (!isSupportedPlatform()) { diff --git a/lib/src/posthog_flutter_platform_interface.dart b/lib/src/posthog_flutter_platform_interface.dart index 8e1a5397..151e3158 100644 --- a/lib/src/posthog_flutter_platform_interface.dart +++ b/lib/src/posthog_flutter_platform_interface.dart @@ -129,6 +129,13 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { throw UnimplementedError('flush() has not been implemented.'); } + Future captureException( + {required Object error, + StackTrace? stackTrace, + Map? properties}) { + throw UnimplementedError('captureException() has not been implemented.'); + } + Future close() { throw UnimplementedError('close() has not been implemented.'); } diff --git a/lib/src/posthog_flutter_web_handler.dart b/lib/src/posthog_flutter_web_handler.dart index e8f2a357..eefd456e 100644 --- a/lib/src/posthog_flutter_web_handler.dart +++ b/lib/src/posthog_flutter_web_handler.dart @@ -210,6 +210,9 @@ Future handleWebMethodCall(MethodCall call) async { case 'surveyAction': // not supported on Web break; + case 'captureException': + // not implemented on Web + break; default: throw PlatformException( code: 'Unimplemented', diff --git a/lib/src/utils/property_normalizer.dart b/lib/src/utils/property_normalizer.dart new file mode 100644 index 00000000..b7885907 --- /dev/null +++ b/lib/src/utils/property_normalizer.dart @@ -0,0 +1,54 @@ +import 'dart:typed_data'; + +class PropertyNormalizer { + /// Normalizes a map of properties to ensure they are serializable through method channels. + /// + /// Unsupported types are converted to strings using toString(). + /// Nested maps and lists are recursively normalized. + /// Nulls are stripped. + static Map normalize(Map properties) { + final result = {}; + for (final entry in properties.entries) { + final normalizedValue = _normalizeValue(entry.value); + if (normalizedValue != null) { + result[entry.key] = normalizedValue; + } + } + return result; + } + + /// Normalizes a single value to ensure it's serializable through method channels. + static Object? _normalizeValue(Object? value) { + if (_isSupported(value)) { + return value; + } else if (value is List) { + return value.map((e) => _normalizeValue(e)).toList(); + } else if (value is Set) { + return value.map((e) => _normalizeValue(e)).toList(); + } else if (value is Map) { + final result = {}; + for (final entry in value.entries) { + final normalizedValue = _normalizeValue(entry.value); + if (normalizedValue != null) { + result[entry.key.toString()] = normalizedValue; + } + } + return result; + } else { + return value.toString(); + } + } + + /// Checks if a value is natively supported by StandardMessageCodec + /// see: https://api.flutter.dev/flutter/services/StandardMessageCodec-class.html + static bool _isSupported(Object? value) { + return value == null || + value is bool || + value is String || + value is num || + value is Uint8List || + value is Int32List || + value is Int64List || + value is Float64List; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 8a0ff19a..161836f5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,8 +7,8 @@ issue_tracker: https://github.com/posthog/posthog-flutter/issues documentation: https://github.com/posthog/posthog-flutter#readme environment: - sdk: '>=3.3.0 <4.0.0' - flutter: '>=3.19.0' + sdk: '>=3.4.0 <4.0.0' + flutter: '>=3.22.0' dependencies: flutter: @@ -18,6 +18,7 @@ dependencies: plugin_platform_interface: ^2.0.2 # plugin_platform_interface depends on meta anyway meta: ^1.3.0 + stack_trace: ^1.12.0 dev_dependencies: flutter_lints: ^5.0.0 diff --git a/test/dart_exception_processor_test.dart b/test/dart_exception_processor_test.dart new file mode 100644 index 00000000..e336f451 --- /dev/null +++ b/test/dart_exception_processor_test.dart @@ -0,0 +1,464 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/error_tracking/dart_exception_processor.dart'; + +void main() { + group('DartExceptionProcessor', () { + test('processes exception with correct properties', () { + final mainException = StateError('Test exception message'); + final stackTrace = StackTrace.fromString(''' +#0 Object.noSuchMethod (package:posthog-flutter:1884:25) +#1 Trace.terse. (file:///usr/local/google-old/home/goog/dart/dart/pkg/stack_trace/lib/src/trace.dart:47:21) +#2 IterableMixinWorkaround.reduce (dart:collection:29:29) +#3 List.reduce (dart:core-patch:1247:42) +#4 Trace.terse (file:///usr/local/google-old/home/goog/dart/dart/pkg/stack_trace/lib/src/trace.dart:40:35) +#5 format (file:///usr/local/google-old/home/goog/dart/dart/pkg/stack_trace/lib/stack_trace.dart:24:28) +#6 main. (file:///usr/local/google-old/home/goog/dart/dart/test.dart:21:29) +#7 _CatchErrorFuture._sendError (dart:async:525:24) +#8 _FutureImpl._setErrorWithoutAsyncTrace (dart:async:393:26) +#9 _FutureImpl._setError (dart:async:378:31) +#10 _ThenFuture._sendValue (dart:async:490:16) +#11 _FutureImpl._handleValue. (dart:async:349:28) +#12 Timer.run. (dart:async:2402:21) +#13 Timer.Timer. (dart:async-patch:15:15) +'''); + + final additionalProperties = {'custom_key': 'custom_value'}; + + // Process the exception + final result = DartExceptionProcessor.processException( + error: mainException, + stackTrace: stackTrace, + properties: additionalProperties, + inAppIncludes: ['posthog_flutter_example'], + inAppExcludes: [], + inAppByDefault: true, + ); + + // Verify basic structure + expect(result, isA>()); + expect(result.containsKey('\$exception_level'), isTrue); + expect(result.containsKey('\$exception_list'), isTrue); + expect( + result.containsKey('custom_key'), isTrue); // Properties are in root + + // Verify custom properties are preserved + expect(result['custom_key'], equals('custom_value')); + + // Verify exception list structure + final exceptionList = + result['\$exception_list'] as List>; + expect(exceptionList, isNotEmpty); + + final mainExceptionData = exceptionList.first; + + // Verify main exception structure + expect(mainExceptionData['type'], equals('StateError')); + expect( + mainExceptionData['value'], + equals( + 'Bad state: Test exception message')); // StateError adds prefix + expect(mainExceptionData['thread_id'], + isA()); // Should be hash-based thread ID + + // Verify mechanism structure + final mechanism = mainExceptionData['mechanism'] as Map; + expect(mechanism['handled'], isTrue); + expect(mechanism['synthetic'], isFalse); + expect(mechanism['type'], equals('generic')); + + // Verify stack trace structure + final stackTraceData = + mainExceptionData['stacktrace'] as Map; + expect(stackTraceData['type'], equals('raw')); + + final frames = stackTraceData['frames'] as List>; + expect(frames, isNotEmpty); + + // Verify first frame structure (should be main function) + final firstFrame = frames.first; + expect(firstFrame.containsKey('function'), isTrue); + expect(firstFrame.containsKey('filename'), isTrue); + expect(firstFrame.containsKey('lineno'), isTrue); + expect(firstFrame['platform'], equals('dart')); + + // Verify inApp detection works - just check that the field exists and is boolean + expect(firstFrame['in_app'], isTrue); + + // Check that dart core frames are marked as not inApp + final dartFrame = frames.firstWhere( + (frame) => + frame['package'] == null && + (frame['abs_path']?.contains('dart:') == true), + orElse: () => {}, + ); + if (dartFrame.isNotEmpty) { + expect(dartFrame['in_app'], isFalse); + } + }); + + test('handles inAppIncludes configuration correctly', () { + final exception = Exception('Test exception'); + final stackTrace = StackTrace.fromString(''' +#0 main (package:my_app/main.dart:25:7) +#1 helper (package:third_party/helper.dart:10:5) +#2 core (dart:core/core.dart:100:10) +'''); + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: {}, + inAppIncludes: ['my_app'], + inAppExcludes: [], + inAppByDefault: false, // third_party is not included + ); + + final exceptionData = + result['\$exception_list'] as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; + + // Find frames by package + final myAppFrame = frames.firstWhere((f) => f['package'] == 'my_app'); + final thirdPartyFrame = + frames.firstWhere((f) => f['package'] == 'third_party'); + + // Verify inApp detection + expect(myAppFrame['in_app'], isTrue); // Explicitly included + expect(thirdPartyFrame['in_app'], isFalse); // Not included + }); + + test('handles inAppExcludes configuration correctly', () { + final exception = Exception('Test exception'); + final stackTrace = StackTrace.fromString(''' +#0 main (package:my_app/main.dart:25:7) +#1 analytics (package:analytics_lib/tracker.dart:50:3) +#2 helper (package:helper_lib/utils.dart:15:8) +'''); + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: {}, + inAppIncludes: [], + inAppExcludes: ['analytics_lib'], + inAppByDefault: true, // all inApp except inAppExcludes + ); + + final exceptionData = + result['\$exception_list'] as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; + + // Find frames by package + final myAppFrame = frames.firstWhere((f) => f['package'] == 'my_app'); + final analyticsFrame = + frames.firstWhere((f) => f['package'] == 'analytics_lib'); + final helperFrame = + frames.firstWhere((f) => f['package'] == 'helper_lib'); + + // Verify inApp detection + expect(myAppFrame['in_app'], isTrue); // Default true, not excluded + expect(analyticsFrame['in_app'], isFalse); // Explicitly excluded + expect(helperFrame['in_app'], isTrue); // Default true, not excluded + }); + + test('gives precedence to inAppIncludes over inAppExcludes', () { + // Test the precedence logic directly with a simple scenario + final exception = Exception('Test exception'); + final stackTrace = + StackTrace.fromString('#0 test (package:test_package/test.dart:1:1)'); + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: {}, + inAppIncludes: ['test_package'], // Include test_package + inAppExcludes: ['test_package'], // But also exclude test_package + inAppByDefault: false, + ); + + final exceptionData = + result['\$exception_list'] as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; + + // Find any frame from test_package + final testFrame = frames.firstWhere( + (frame) => frame['package'] == 'test_package', + orElse: () => {}, + ); + + // If we found the frame, test precedence + if (testFrame.isNotEmpty) { + expect(testFrame['in_app'], isTrue, + reason: 'inAppIncludes should take precedence over inAppExcludes'); + } else { + // Just verify that the configuration was processed without error + expect(frames, isA()); + } + }); + + test('processes exception types correctly', () { + final testCases = [ + // Real Exception/Error objects + { + 'exception': Exception('Exception test'), + 'expectedType': '_Exception' + }, + { + 'exception': StateError('StateError test'), + 'expectedType': 'StateError' + }, + { + 'exception': ArgumentError('ArgumentError test'), + 'expectedType': 'ArgumentError' + }, + { + 'exception': FormatException('FormatException test'), + 'expectedType': 'FormatException' + }, + // Primitive types + {'exception': 'Plain string error', 'expectedType': 'String'}, + {'exception': 42, 'expectedType': 'int'}, + {'exception': true, 'expectedType': 'bool'}, + {'exception': 3.14, 'expectedType': 'double'}, + {'exception': [], 'expectedType': 'List'}, + { + 'exception': ['some', 'error'], + 'expectedType': 'List' + }, + {'exception': {}, 'expectedType': '_Map'}, + ]; + + for (final testCase in testCases) { + final exception = testCase['exception']!; + final expectedType = testCase['expectedType'] as String; + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), + properties: {}, + ); + + final exceptionList = + result['\$exception_list'] as List>; + final exceptionData = exceptionList.first; + + expect(exceptionData['type'], equals(expectedType), + reason: 'Exception type mismatch for: $exception'); + + // Verify the exception value is present and is a string + expect(exceptionData['value'], isA()); + expect(exceptionData['value'], isNotEmpty); + } + }); + + test('generates consistent thread IDs', () { + final exception = Exception('Test exception'); + final stackTrace = StackTrace.fromString('#0 test (test.dart:1:1)'); + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: {}, + ); + + final exceptionData = + result['\$exception_list'] as List>; + final threadId = exceptionData.first['thread_id']; + + final result2 = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: {}, + ); + final exceptionData2 = + result2['\$exception_list'] as List>; + final threadId2 = exceptionData2.first['thread_id']; + + expect(threadId, equals(threadId2)); // Should be consistent + }); + + test('generates stack trace when none provided', () { + final exception = Exception('Test exception'); // will have no stack trace + + final result = DartExceptionProcessor.processException( + error: exception, + // No stackTrace provided - should generate one + ); + + final exceptionData = + result['\$exception_list'] as List>; + final stackTraceData = exceptionData.first['stacktrace']; + + // Should have generated a stack trace + expect(stackTraceData, isNotNull); + expect(stackTraceData['frames'], isA()); + expect((stackTraceData['frames'] as List).isNotEmpty, isTrue); + + // Should be marked as synthetic since we generated it + expect(exceptionData.first['mechanism']['synthetic'], isTrue); + }); + + test('uses error.stackTrace when available', () { + try { + throw StateError('Test error'); + } catch (error) { + final result = DartExceptionProcessor.processException( + error: error, + // No stackTrace provided - should generate one from error.stackTrace + ); + + final exceptionData = + result['\$exception_list'] as List>; + final stackTraceData = exceptionData.first['stacktrace']; + + // Should have a stack trace from the Error object + expect(stackTraceData, isNotNull); + expect(stackTraceData['frames'], isA()); + + // Should not be marked as synthetic since we did not generate a stack trace + expect(exceptionData.first['mechanism']['synthetic'], isFalse); + } + }); + + test('removes PostHog frames when stack trace is generated', () { + final exception = Exception('Test exception'); + + // Create a mock stack trace that includes PostHog frames + final mockStackTrace = StackTrace.fromString(''' +#0 DartExceptionProcessor.processException (package:posthog_flutter/src/error_tracking/dart_exception_processor.dart:28:7) +#1 PosthogFlutterIO.captureException (package:posthog_flutter/src/posthog_flutter_io.dart:435:29) +#2 Posthog.captureException (package:posthog_flutter/src/posthog.dart:136:7) +#3 userFunction (package:my_app/main.dart:100:5) +#4 PosthogFlutterIO.setup (package:posthog_flutter/src/posthog.dart:136:7) +#5 main (package:some_lib/lib.dart:50:3) +'''); + + final result = DartExceptionProcessor.processException( + error: exception, + stackTraceProvider: () { + return mockStackTrace; + }, + ); + + final exceptionData = + result['\$exception_list'] as List>; + final frames = exceptionData.first['stacktrace']['frames'] as List; + + // Should include frames since we provided the stack trace + expect(frames[0]['package'], 'my_app'); + expect(frames[0]['filename'], 'main.dart'); + // earlier PH frames should be untouched + expect(frames[1]['package'], 'posthog_flutter'); + expect(frames[1]['filename'], 'posthog.dart'); + expect(frames[2]['package'], 'some_lib'); + expect(frames[2]['filename'], 'lib.dart'); + }); + + test('marks generated stack frames as synthetic', () { + final exception = Exception('Test exception'); // will have no stack trace + + final result = DartExceptionProcessor.processException( + error: exception, + // No stackTrace provided - should generate one + ); + + final exceptionData = + result['\$exception_list'] as List>; + + // Should be marked as synthetic since we generated it + expect(exceptionData.first['mechanism']['synthetic'], isTrue); + }); + + test('does not mark exceptions as synthetic when stack trace is provided', + () { + final realExceptions = [ + Exception('Real exception'), + StateError('Real error'), + ArgumentError('Real argument error'), + ]; + + for (final exception in realExceptions) { + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), + ); + + final exceptionData = + result['\$exception_list'] as List>; + + expect(exceptionData.first['mechanism']['synthetic'], isFalse); + } + }); + + test('allows user properties to override system properties', () { + final exception = Exception('Test exception'); + final stackTrace = StackTrace.fromString('#0 test (test.dart:1:1)'); + + // Properties that override system properties + final overrideProperties = { + '\$exception_level': 'warning', // Override default 'error' + 'custom_property': 'custom_value', // Additional custom property + }; + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: stackTrace, + properties: overrideProperties, + ); + + // Verify that user properties take precedence + expect(result['\$exception_level'], equals('warning')); + expect(result['custom_property'], equals('custom_value')); + }); + + test('inserts asynchronous gap frames between traces', () async { + final exception = Exception('Async test exception'); + + // Create an async stack trace by throwing from an async function + StackTrace? asyncStackTrace; + try { + await _asyncFunction1(); + } catch (e, stackTrace) { + asyncStackTrace = stackTrace; + } + + final result = DartExceptionProcessor.processException( + error: exception, + stackTrace: asyncStackTrace, + ); + + final exceptionData = + result['\$exception_list'] as List>; + final frames = exceptionData.first['stacktrace']['frames'] + as List>; + + // Look for asynchronous gap frames + final gapFrames = frames + .where((frame) => frame['abs_path'] == '') + .toList(); + + // Should have at least one gap frame in an async stack trace + expect(gapFrames, isNotEmpty, + reason: 'Async stack traces should contain gap frames'); + + // Verify gap frame structure + final gapFrame = gapFrames.first; + expect(gapFrame['platform'], equals('dart')); + expect(gapFrame['in_app'], isFalse); + expect(gapFrame['abs_path'], equals('')); + }); + }); +} + +// Helper functions to generate async stack traces for testing +Future _asyncFunction1() async { + await _asyncFunction2(); +} + +Future _asyncFunction2() async { + await Future.delayed(Duration.zero); // Force async boundary + throw StateError('Async error for testing'); +} diff --git a/test/property_normalizer_test.dart b/test/property_normalizer_test.dart new file mode 100644 index 00000000..87b93127 --- /dev/null +++ b/test/property_normalizer_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/utils/property_normalizer.dart'; + +void main() { + group('PropertyNormalizer', () { + test('normalizes supported types correctly', () { + final properties = { + 'null_value': null, + 'bool_value': true, + 'int_value': 42, + 'double_value': 3.14, + 'string_value': 'hello', + }; + + final result = PropertyNormalizer.normalize(properties); + + // Null values are filtered out by the normalizer + final expected = { + 'bool_value': true, + 'int_value': 42, + 'double_value': 3.14, + 'string_value': 'hello', + }; + + expect(result, equals(expected)); + }); + + test('converts unsupported types to strings', () { + final customObject = DateTime(2023, 1, 1); + final properties = { + 'custom_object': customObject, + 'symbol': #test, + }; + + final result = PropertyNormalizer.normalize(properties); + + expect(result['custom_object'], equals(customObject.toString())); + expect(result['symbol'], equals('Symbol("test")')); + }); + + test('normalizes multidimensional lists', () { + final properties = { + 'simple_list': [1, 2, 3], + 'mixed_list': [1, 'hello', true], + '2d_list': [ + [1, 2], + ['a', 'b'] + ], + }; + + final result = PropertyNormalizer.normalize(properties); + + expect(result['simple_list'], equals([1, 2, 3])); + final mixedList = result['mixed_list'] as List; + expect(mixedList[0], equals(1)); + expect(mixedList[1], equals('hello')); + expect(mixedList[2], equals(true)); + expect( + result['2d_list'], + equals([ + [1, 2], + ['a', 'b'] + ])); + }); + + test('normalizes nested maps', () { + final properties = { + 'nested_map': { + 'inner_string': 'value', + 'inner_number': 123, + 'deeply_nested': { + 'level2': { + 'level3': 'deep_value', + 1: 'deep_value', + }, + }, + }, + }; + + final result = PropertyNormalizer.normalize(properties); + + final nestedMap = result['nested_map'] as Map; + expect(nestedMap['inner_string'], equals('value')); + expect(nestedMap['inner_number'], equals(123)); + + final deeplyNested = nestedMap['deeply_nested'] as Map; + final level2 = deeplyNested['level2'] as Map; + expect(level2['level3'], equals('deep_value')); + expect(level2['1'], equals('deep_value')); + }); + + test('handles maps with non-string keys', () { + final properties = { + 'map_with_int_keys': { + 1: 'one', + 2: 'two', + }, + }; + + final result = PropertyNormalizer.normalize(properties); + + final normalizedMap = result['map_with_int_keys'] as Map; + expect(normalizedMap['1'], equals('one')); // Key converted to string + expect(normalizedMap['2'], equals('two')); // Key converted to string + }); + }); +} From f71474eddf7526f43cd31069234d985702a29644 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Fri, 31 Oct 2025 16:02:00 +0200 Subject: [PATCH 29/34] Update version --- CHANGELOG.md | 2 ++ android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt | 2 +- ios/Classes/PostHogFlutterVersion.swift | 2 +- pubspec.yaml | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3894822a..2630167f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +## 5.7.0 + - feat: add manual error capture ([#212](https://github.com/PostHog/posthog-flutter/pull/212)) - **Note**: The following features are not yet supported: - Automatic exception capture diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt index 32b3a5b4..44ec0d41 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt @@ -1,3 +1,3 @@ package com.posthog.flutter -internal val postHogVersion = "5.6.0" +internal val postHogVersion = "5.7.0" diff --git a/ios/Classes/PostHogFlutterVersion.swift b/ios/Classes/PostHogFlutterVersion.swift index ac372ee0..ef1037a4 100644 --- a/ios/Classes/PostHogFlutterVersion.swift +++ b/ios/Classes/PostHogFlutterVersion.swift @@ -8,4 +8,4 @@ import Foundation // This property is internal only -let postHogFlutterVersion = "5.6.0" +let postHogFlutterVersion = "5.7.0" diff --git a/pubspec.yaml b/pubspec.yaml index 161836f5..045dbadf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: posthog_flutter description: Flutter implementation of PostHog client for iOS, Android and Web -version: 5.6.0 +version: 5.7.0 homepage: https://www.posthog.com repository: https://github.com/posthog/posthog-flutter issue_tracker: https://github.com/posthog/posthog-flutter/issues From 29d344c45eb4031f0465966c63afbba2111325b5 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 3 Nov 2025 11:45:16 +0200 Subject: [PATCH 30/34] feat: surveys GA (#215) * feat: surveys GA * fix: remove unused import * fix: add note on PosthogObserver * fix: add web doc --- CHANGELOG.md | 3 +++ lib/src/posthog_config.dart | 16 +++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2630167f..febd8ac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## Next +- feat: surveys GA ([#215](https://github.com/PostHog/posthog-flutter/pull/215)) +> Note: Surveys are now enabled by default. + ## 5.7.0 - feat: add manual error capture ([#212](https://github.com/PostHog/posthog-flutter/pull/212)) diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index 00ccb9a5..40264f93 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -1,5 +1,3 @@ -import 'package:meta/meta.dart'; - enum PostHogPersonProfiles { never, always, identifiedOnly } enum PostHogDataMode { wifi, cellular, any } @@ -31,13 +29,17 @@ class PostHogConfig { /// iOS only var dataMode = PostHogDataMode.any; - /// Enable Surveys (Currently for iOS only) + /// Enable Surveys /// - /// Note: Please note that after calling Posthog().close(), surveys will not be rendered until the SDK is re-initialized and the next navigation event occurs. + /// **Notes:** + /// - After calling `Posthog().close()`, surveys will not be rendered until the SDK is re-initialized and the next navigation event occurs. + /// - You must install `PosthogObserver` in your app for surveys to display + /// - See: https://posthog.com/docs/surveys/installation?tab=Flutter#step-two-install-posthogobserver + /// - For Flutter web, this setting will be ignored. Surveys on web use the JavaScript Web SDK instead. + /// - See: https://posthog.com/docs/surveys/installation?tab=Web /// - /// Experimental. Defaults to false. - @experimental - var surveys = false; + /// Defaults to true. + var surveys = true; /// Configuration for error tracking and exception capture final errorTrackingConfig = PostHogErrorTrackingConfig(); From 6da3f30a65b7f630a4763b90be3c21c0b683e2cd Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Mon, 3 Nov 2025 11:46:40 +0200 Subject: [PATCH 31/34] Update version --- CHANGELOG.md | 2 ++ android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt | 2 +- ios/Classes/PostHogFlutterVersion.swift | 2 +- pubspec.yaml | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index febd8ac1..7388ce72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +## 5.8.0 + - feat: surveys GA ([#215](https://github.com/PostHog/posthog-flutter/pull/215)) > Note: Surveys are now enabled by default. diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt index 44ec0d41..52a9feb8 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt @@ -1,3 +1,3 @@ package com.posthog.flutter -internal val postHogVersion = "5.7.0" +internal val postHogVersion = "5.8.0" diff --git a/ios/Classes/PostHogFlutterVersion.swift b/ios/Classes/PostHogFlutterVersion.swift index ef1037a4..7a3fb63a 100644 --- a/ios/Classes/PostHogFlutterVersion.swift +++ b/ios/Classes/PostHogFlutterVersion.swift @@ -8,4 +8,4 @@ import Foundation // This property is internal only -let postHogFlutterVersion = "5.7.0" +let postHogFlutterVersion = "5.8.0" diff --git a/pubspec.yaml b/pubspec.yaml index 045dbadf..69dbe0f3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: posthog_flutter description: Flutter implementation of PostHog client for iOS, Android and Web -version: 5.7.0 +version: 5.8.0 homepage: https://www.posthog.com repository: https://github.com/posthog/posthog-flutter issue_tracker: https://github.com/posthog/posthog-flutter/issues From 3a739710f6bbae8e5fce241858c5eada20ccf4ef Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 5 Nov 2025 15:20:32 +0200 Subject: [PATCH 32/34] feat: autocapture unhandled exceptions (#214) * feat: add public interface * feat: add config and exception processor * feat: native plugins * chore: update sample app * feat: add unit tests * feat: handle primitives * chore: update changelog * fix: package and module * feat: add autocapture integration * chore: update changelog * fix: format * fix: format * fix: use Object vs dynamic * chore: rename folder * fix: make stackTrace optional * fix: clean generated stack trace * feat: add unit tests * fix: make function optional * fix: drop primitive check * fix: skip module * fix: make thread_id optional * fix: update config * fix: error type * fix: doc * fix: handle empty stack trace * fix: allow overwriting exception properties * fix: normalize props * chore: bump min dart and flutter version * fix: generate event timestamp flutter side * fix: remove handled from public api * fix: platform call arguments * fix: example app * fix: do not try to parse exception package * fix: normalize props * fix: normalize sets * fix: remove handled from public interface * fix: web handler * fix: replace hof with direct iteration * fix: * feat: add web support * chore: add config comment * feat: add async gap franes * Revert "feat: add web support" This reverts commit 6c0529ccfaa634514a12cfeaa245568ddfad41c1. * fix: simplify config * feat: add config for native autocapture * fix: avoid null-assertion * fix: always restore original handlers * fix: wrap error in PostHogException * feat: add test cases * feat: capture additional details from FlutterErrorDetails * fix: make install and uninstall methods private * fix: do not modify behavior * feat: add current isolate error handling * fix: remove tests * fix: annotate as internal * fix: flutter analyze * fix: do not install PlatformDispatcher.instance.onError on web * chore: update changelog * fix: build * chore: update changelog * fix: address feedback * fix: mark IsolateErrorHandler as internal for now * fix: type inference * fix: update changelog Co-authored-by: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> * fix: platform check * chore: update sample project --------- Co-authored-by: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> --- CHANGELOG.md | 9 + .../posthog/flutter/PosthogFlutterPlugin.kt | 10 + example/lib/main.dart | 105 +++++++- .../dart_exception_processor.dart | 24 +- .../error_tracking/isolate_handler_io.dart | 31 +++ .../error_tracking/isolate_handler_web.dart | 16 ++ ...rror_tracking_autocapture_integration.dart | 247 ++++++++++++++++++ lib/src/error_tracking/posthog_exception.dart | 16 ++ lib/src/posthog.dart | 31 ++- lib/src/posthog_config.dart | 71 ++++- lib/src/posthog_flutter_io.dart | 2 +- test/dart_exception_processor_test.dart | 110 ++++++++ ...sthog_flutter_platform_interface_fake.dart | 27 ++ 13 files changed, 687 insertions(+), 12 deletions(-) create mode 100644 lib/src/error_tracking/isolate_handler_io.dart create mode 100644 lib/src/error_tracking/isolate_handler_web.dart create mode 100644 lib/src/error_tracking/posthog_error_tracking_autocapture_integration.dart create mode 100644 lib/src/error_tracking/posthog_exception.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7388ce72..e88dcbcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ ## Next +- feat: add autocapture exceptions ([#214](https://github.com/PostHog/posthog-flutter/pull/214)) + - **Limitations**: + - No Flutter web support + - No native iOS exception capture + - No native C/C++ exception capture on Android (Java/Kotlin only) + - No stacktrace demangling for obfuscated builds ([--obfuscate](https://docs.flutter.dev/deployment/obfuscate) and [--split-debug-info](https://docs.flutter.dev/deployment/obfuscate)) for Dart code and [isMinifyEnabled](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization) for Java/Kotlin code + - No [source code context](/docs/error-tracking/stack-traces) + - No background isolate error capture + ## 5.8.0 - feat: surveys GA ([#215](https://github.com/PostHog/posthog-flutter/pull/215)) diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 80217cb3..089743c2 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -293,6 +293,16 @@ class PosthogFlutterPlugin : } } + // Configure error tracking autocapture + posthogConfig.getIfNotNull>("errorTrackingConfig") { errorConfig -> + errorConfig.getIfNotNull("captureNativeExceptions") { + errorTrackingConfig.autoCapture = it + } + errorConfig.getIfNotNull>("inAppIncludes") { includes -> + errorTrackingConfig.inAppIncludes.addAll(includes) + } + } + sdkName = "posthog-flutter" sdkVersion = postHogVersion } diff --git a/example/lib/main.dart b/example/lib/main.dart index 686374a3..d05c2ad4 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; @@ -16,6 +18,15 @@ Future main() async { config.sessionReplayConfig.maskAllImages = false; config.sessionReplayConfig.throttleDelay = const Duration(milliseconds: 1000); config.flushAt = 1; + + // Configure error tracking and exception capture + config.errorTrackingConfig.captureFlutterErrors = + true; // Capture Flutter framework errors + config.errorTrackingConfig.capturePlatformDispatcherErrors = + true; // Capture Dart runtime errors + config.errorTrackingConfig.captureIsolateErrors = + true; // Capture isolate errors + await Posthog().setup(config); runApp(const MyApp()); @@ -243,7 +254,7 @@ class InitialScreenState extends State { const Padding( padding: EdgeInsets.all(8.0), child: Text( - "Error Tracking", + "Error Tracking - Manual", style: TextStyle(fontWeight: FontWeight.bold), ), ), @@ -300,6 +311,98 @@ class InitialScreenState extends State { child: const Text("Capture Exception (Missing Stack)"), ), const Divider(), + const Padding( + padding: EdgeInsets.all(8.0), + child: Text( + "Error Tracking - Autocapture", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + onPressed: () { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('Flutter error triggered! Check PostHog.'), + backgroundColor: Colors.red, + duration: Duration(seconds: 3), + ), + ); + } + + // Test Flutter error handler by throwing in widget context + throw const CustomException( + 'Test Flutter error for autocapture', + code: 'FlutterErrorTest', + additionalData: {'test_type': 'flutter_error'}); + }, + child: const Text("Test Flutter Error Handler"), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + onPressed: () { + // Test PlatformDispatcher error handler with Future + Future.delayed(Duration.zero, () { + throw const CustomException( + 'Test PlatformDispatcher error for autocapture', + code: 'PlatformDispatcherTest', + additionalData: { + 'test_type': 'platform_dispatcher_error' + }); + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Dart runtime error triggered! Check PostHog.'), + backgroundColor: Colors.blue, + duration: Duration(seconds: 3), + ), + ); + } + }, + child: const Text("Test Dart Error Handler"), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.purple, + foregroundColor: Colors.white, + ), + onPressed: () { + // Test isolate error listener by throwing in an async callback + Timer(Duration.zero, () { + throw const CustomException( + 'Isolate error for testing', + code: 'IsolateHandlerTest', + additionalData: { + 'test_type': 'isolate_error_listener_timer', + }, + ); + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('Isolate error triggered! Check PostHog.'), + backgroundColor: Colors.purple, + duration: Duration(seconds: 3), + ), + ); + } + }, + child: const Text("Test Isolate Error Handler"), + ), + const Divider(), const Padding( padding: EdgeInsets.all(8.0), child: Text( diff --git a/lib/src/error_tracking/dart_exception_processor.dart b/lib/src/error_tracking/dart_exception_processor.dart index 4782d3cc..15451423 100644 --- a/lib/src/error_tracking/dart_exception_processor.dart +++ b/lib/src/error_tracking/dart_exception_processor.dart @@ -1,5 +1,6 @@ import 'package:stack_trace/stack_trace.dart'; import 'utils/isolate_utils.dart' as isolate_utils; +import 'posthog_exception.dart'; class DartExceptionProcessor { /// Converts Dart error/exception and stack trace to PostHog exception format @@ -12,12 +13,23 @@ class DartExceptionProcessor { bool inAppByDefault = true, StackTrace Function()? stackTraceProvider, //for testing }) { + // Extract PostHog metadata if error is wrapped in PostHogException + var mechanismType = 'generic'; + var handled = true; + var currentError = error; + + if (error is PostHogException) { + handled = error.handled; + mechanismType = error.mechanism; + currentError = error.source; + } + StackTrace? effectiveStackTrace = stackTrace; bool isGeneratedStackTrace = false; // If it's an Error, try to use its built-in stackTrace - if (error is Error) { - effectiveStackTrace ??= error.stackTrace; + if (currentError is Error) { + effectiveStackTrace ??= currentError.stackTrace; } // If still null or empty, get current stack trace @@ -41,7 +53,7 @@ class DartExceptionProcessor { ) : >[]; - final errorType = _getExceptionType(error); + final errorType = _getExceptionType(currentError); // Mark exception as synthetic if: // - runtimeType.toString() returned empty/null (fallback to 'Error' type) @@ -53,14 +65,14 @@ class DartExceptionProcessor { final exceptionData = { 'type': errorType ?? 'Error', 'mechanism': { - 'handled': true, // always true for now + 'handled': handled, 'synthetic': isSynthetic, - 'type': 'generic', + 'type': mechanismType, } }; // Add exception message, if available - final errorMessage = error.toString(); + final errorMessage = currentError.toString(); if (errorMessage.isNotEmpty) { exceptionData['value'] = errorMessage; } diff --git a/lib/src/error_tracking/isolate_handler_io.dart b/lib/src/error_tracking/isolate_handler_io.dart new file mode 100644 index 00000000..56a8b987 --- /dev/null +++ b/lib/src/error_tracking/isolate_handler_io.dart @@ -0,0 +1,31 @@ +import 'dart:isolate'; + +import 'package:meta/meta.dart'; + +/// Native platform implementation of isolate error handling +@internal +class IsolateErrorHandler { + RawReceivePort? _isolateErrorPort; + + /// Add error listener to current isolate (should be main isolate) + void addErrorListener(Function(Object?) onError) { + _isolateErrorPort = RawReceivePort(onError); + final isolateErrorPort = _isolateErrorPort; + if (isolateErrorPort != null) { + Isolate.current.addErrorListener(isolateErrorPort.sendPort); + } + } + + /// Remove error listener and clean up + void removeErrorListener() { + final isolateErrorPort = _isolateErrorPort; + if (isolateErrorPort != null) { + isolateErrorPort.close(); + Isolate.current.removeErrorListener(isolateErrorPort.sendPort); + _isolateErrorPort = null; + } + } + + /// Get current isolate name + String? get isolateDebugName => Isolate.current.debugName; +} diff --git a/lib/src/error_tracking/isolate_handler_web.dart b/lib/src/error_tracking/isolate_handler_web.dart new file mode 100644 index 00000000..0f88e412 --- /dev/null +++ b/lib/src/error_tracking/isolate_handler_web.dart @@ -0,0 +1,16 @@ +/// Web platform stub implementation of isolate error handling +/// Isolates are not available on web, so this is a no-op implementation +class IsolateErrorHandler { + /// Add error listener to current isolate (no-op on web) + void addErrorListener(Function(dynamic) onError) { + // No-op: Isolates are not available on web + } + + /// Remove error listener and clean up (no-op on web) + void removeErrorListener() { + // No-op: Isolates are not available on web + } + + /// Get current isolate name (always 'main' on web) + String? get isolateDebugName => 'main'; +} diff --git a/lib/src/error_tracking/posthog_error_tracking_autocapture_integration.dart b/lib/src/error_tracking/posthog_error_tracking_autocapture_integration.dart new file mode 100644 index 00000000..e96f8ec0 --- /dev/null +++ b/lib/src/error_tracking/posthog_error_tracking_autocapture_integration.dart @@ -0,0 +1,247 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:posthog_flutter/src/util/platform_io_stub.dart' + if (dart.library.io) 'package:posthog_flutter/src/util/platform_io_real.dart'; + +import 'isolate_handler_io.dart' + if (dart.library.html) 'isolate_handler_web.dart'; +import 'package:posthog_flutter/src/util/logging.dart'; + +import '../posthog_flutter_platform_interface.dart'; +import '../posthog_config.dart'; +import 'posthog_exception.dart'; + +/// Handles automatic capture of Flutter and Dart exceptions +class PostHogErrorTrackingAutoCaptureIntegration { + final PostHogErrorTrackingConfig _config; + final PosthogFlutterPlatformInterface _posthog; + + // Store original handlers (we'll chain with them from our handler) + FlutterExceptionHandler? _originalFlutterErrorHandler; + ErrorCallback? _originalPlatformErrorHandler; + + // Isolate error handling + final IsolateErrorHandler _isolateErrorHandler = IsolateErrorHandler(); + + bool _isEnabled = false; + + static PostHogErrorTrackingAutoCaptureIntegration? _instance; + + PostHogErrorTrackingAutoCaptureIntegration._({ + required PostHogErrorTrackingConfig config, + required PosthogFlutterPlatformInterface posthog, + }) : _config = config, + _posthog = posthog; + + /// Install the autocapture integration (can only be installed once) + static PostHogErrorTrackingAutoCaptureIntegration? install({ + required PostHogErrorTrackingConfig config, + required PosthogFlutterPlatformInterface posthog, + }) { + if (_instance != null) { + debugPrint( + 'PostHog: Error tracking autocapture integration is already installed. Call PostHogErrorTrackingAutoCaptureIntegration.uninstall() first.'); + return null; + } + + final instance = PostHogErrorTrackingAutoCaptureIntegration._( + config: config, + posthog: posthog, + ); + + _instance = instance; + + if (config.captureFlutterErrors || + config.capturePlatformDispatcherErrors || + config.captureIsolateErrors) { + instance.start(); + } + + return instance; + } + + /// Uninstall the autocapture integration + static void uninstall() { + if (_instance != null) { + _instance?.stop(); + _instance = null; + } + } + + /// Start automatic exception capture + void start() { + if (_isEnabled) return; + + _isEnabled = true; + + // Set up Flutter error handler if enabled + if (_config.captureFlutterErrors) { + _setupFlutterErrorHandler(); + } + + // Set up platform error handler if enabled + if (_config.capturePlatformDispatcherErrors) { + _setupPlatformErrorHandler(); + } + + // Set up isolate error handler if enabled + if (_config.captureIsolateErrors) { + _setupIsolateErrorHandler(); + } + } + + /// Stop automatic exception capture (restores original handlers) + void stop() { + if (!_isEnabled) return; + + _isEnabled = false; + + // Restore original handlers only if our own handler is still set + if (FlutterError.onError == _posthogFlutterErrorHandler) { + FlutterError.onError = _originalFlutterErrorHandler; + } + if (PlatformDispatcher.instance.onError == _posthogPlatformErrorHandler) { + PlatformDispatcher.instance.onError = _originalPlatformErrorHandler; + } + + // Clean up isolate error handler + _isolateErrorHandler.removeErrorListener(); + + // release refs + _originalFlutterErrorHandler = null; + _originalPlatformErrorHandler = null; + } + + /// Flutter framework error handler + void _setupFlutterErrorHandler() { + // prevent circular calls + if (FlutterError.onError == _posthogFlutterErrorHandler) { + return; + } + + _originalFlutterErrorHandler = FlutterError.onError; + + FlutterError.onError = _posthogFlutterErrorHandler; + } + + void _posthogFlutterErrorHandler(FlutterErrorDetails details) { + if (!details.silent || _config.captureSilentFlutterErrors) { + // Collect additional context information + //(see: https://github.com/getsentry/sentry-dart/blob/a69a51fd1695dd93024be80a50ad05dd990b2b82/packages/flutter/lib/src/integrations/flutter_error_integration.dart#L35-L60) + final context = details.context?.toDescription(); + final collector = details.informationCollector?.call() ?? []; + final information = collector.isNotEmpty + ? (StringBuffer()..writeAll(collector, '\n')).toString() + : null; + final library = details.library; + final errorSummary = details.toStringShort(); + + // Build additional properties with Flutter-specific details + final flutterErrorDetails = { + if (context != null) 'context': context, + if (information != null) 'information': information, + if (library != null) 'library': library, + 'error_summary': errorSummary, + 'silent': details.silent, + }; + + final wrappedError = PostHogException( + source: details.exception, mechanism: 'FlutterError', handled: false); + + _captureException( + error: wrappedError, + stackTrace: details.stack, + properties: {'flutter_error_details': flutterErrorDetails}, + ); + } else { + printIfDebug( + "Error not captured because FlutterErrorDetails.silent is true and captureSilentFlutterErrors is false"); + } + + // Call the original handler, if any + _originalFlutterErrorHandler?.call(details); + } + + /// Platform error handler for Dart runtime errors + void _setupPlatformErrorHandler() { + // On web, PlatformDispatcher.onError is not implemented. Skip for now + // See: https://github.com/flutter/flutter/issues/100277 + if (!isSupportedPlatform()) { + return; + } + + // prevent circular calls + if (PlatformDispatcher.instance.onError == _posthogPlatformErrorHandler) { + return; + } + + _originalPlatformErrorHandler = PlatformDispatcher.instance.onError; + PlatformDispatcher.instance.onError = _posthogPlatformErrorHandler; + } + + bool _posthogPlatformErrorHandler(Object error, StackTrace stackTrace) { + final wrappedError = PostHogException( + source: error, + mechanism: 'PlatformDispatcher', + handled: false, + ); + + _captureException(error: wrappedError, stackTrace: stackTrace); + + // Call the original handler, if any + // False otherwise, so that default fallback mechanism is used + return _originalPlatformErrorHandler?.call(error, stackTrace) ?? false; + } + + /// Isolate error handler for current isolate errors + void _setupIsolateErrorHandler() { + if (!_config.captureIsolateErrors) { + return; + } + + _isolateErrorHandler.addErrorListener(_posthogIsolateErrorHandler); + } + + void _posthogIsolateErrorHandler(Object? error) { + // Isolate errors come as List with [errorString, stackTraceString] + // See: https://api.dartlang.org/stable/2.7.0/dart-isolate/Isolate/addErrorListener.html + if (error is List && error.length == 2) { + final errorString = error.first; + final stackTraceString = error.last; + final stackTrace = _parseStackTrace(stackTraceString); + final isolateName = _isolateErrorHandler.isolateDebugName; + + final wrappedError = PostHogException( + source: errorString, + mechanism: 'isolateError', + handled: false, + ); + + _captureException( + error: wrappedError, + stackTrace: stackTrace, + properties: isolateName != null ? {'isolate_name': isolateName} : null, + ); + } + } + + StackTrace? _parseStackTrace(String? stackTraceString) { + if (stackTraceString == null) return null; + try { + return StackTrace.fromString(stackTraceString); + } catch (e) { + printIfDebug('Failed to parse isolate stack trace: $e'); + return null; + } + } + + Future _captureException({ + required PostHogException error, + required StackTrace? stackTrace, + Map? properties, + }) { + return _posthog.captureException( + error: error, stackTrace: stackTrace, properties: properties); + } +} diff --git a/lib/src/error_tracking/posthog_exception.dart b/lib/src/error_tracking/posthog_exception.dart new file mode 100644 index 00000000..4bfa2155 --- /dev/null +++ b/lib/src/error_tracking/posthog_exception.dart @@ -0,0 +1,16 @@ +import 'package:meta/meta.dart'; + +/// A wrapper exception that carries PostHog-specific metadata +@internal +class PostHogException implements Exception { + /// The original exception/error that was wrapped + final Object source; + final String mechanism; + final bool handled; + + const PostHogException({ + required this.source, + required this.mechanism, + this.handled = false, + }); +} diff --git a/lib/src/posthog.dart b/lib/src/posthog.dart index 2eaec4fe..9c629717 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -1,5 +1,6 @@ import 'package:meta/meta.dart'; +import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autocapture_integration.dart'; import 'posthog_config.dart'; import 'posthog_flutter_platform_interface.dart'; import 'posthog_observer.dart'; @@ -24,9 +25,28 @@ class Posthog { /// com.posthog.posthog.AUTO_INIT: false Future setup(PostHogConfig config) { _config = config; // Store the config + + _installFlutterIntegrations(config); + return _posthog.setup(config); } + void _installFlutterIntegrations(PostHogConfig config) { + // Install exception autocapture if enabled + if (config.errorTrackingConfig.captureFlutterErrors || + config.errorTrackingConfig.capturePlatformDispatcherErrors) { + PostHogErrorTrackingAutoCaptureIntegration.install( + config: config.errorTrackingConfig, + posthog: _posthog, + ); + } + } + + void _uninstallFlutterIntegrations() { + // Uninstall exception autocapture integration + PostHogErrorTrackingAutoCaptureIntegration.uninstall(); + } + @internal PostHogConfig? get config => _config; @@ -85,7 +105,12 @@ class Posthog { Future reset() => _posthog.reset(); - Future disable() => _posthog.disable(); + Future disable() { + // Uninstall Flutter-specific integrations when disabling + _uninstallFlutterIntegrations(); + + return _posthog.disable(); + } Future enable() => _posthog.enable(); @@ -140,6 +165,10 @@ class Posthog { _config = null; _currentScreen = null; PosthogObserver.clearCurrentContext(); + + // Uninstall Flutter integrations + _uninstallFlutterIntegrations(); + return _posthog.close(); } diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index 40264f93..f614044b 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -119,7 +119,9 @@ class PostHogErrorTrackingConfig { /// - Your app's main package (e.g., "package:your_app") /// - Any internal packages you own (e.g., "package:your_company_utils") /// - /// Note: This config will be ignored on web builds + /// **Note:** + /// - Flutter web: Not supported + /// final inAppIncludes = []; /// List of package names to be excluded from inApp frames for exception tracking @@ -134,7 +136,9 @@ class PostHogErrorTrackingConfig { /// - External utility libraries /// - Packages you don't control /// - /// Note: This config will be ignored on web builds + /// **Note:** + /// - Flutter web: Not supported + /// final inAppExcludes = []; /// Configures whether stack trace frames are considered inApp by default @@ -148,14 +152,75 @@ class PostHogErrorTrackingConfig { /// - dart and flutter packages are excluded /// - All other packages are inApp unless in inAppExcludes /// - /// Note: This config will be ignored on web builds + /// **Note:** + /// - Flutter web: Not supported + /// var inAppByDefault = true; + /// Enable automatic capture of Flutter framework errors + /// + /// Controls whether `FlutterError.onError` errors are captured. + /// + /// **Note:** + /// - Flutter web: Not supported + /// + /// Default: true (when autocapture is enabled) + var captureFlutterErrors = false; + + /// Enable capturing of silent Flutter errors + /// + /// Controls whether Flutter errors marked as silent (FlutterErrorDetails.silent = true) are captured. + /// + /// **Note:** + /// - Flutter web: Not supported + /// + /// Default: false + var captureSilentFlutterErrors = false; + + /// Enable automatic capture of Dart runtime errors + /// + /// Controls whether `PlatformDispatcher.onError errors` are captured. + /// + /// **Note:** + /// - Flutter web: Not supported + /// + /// Default: true + var capturePlatformDispatcherErrors = false; + + /// Enable automatic capture of exceptions in the native SDKs (Android only for now) + /// + /// Controls whether native exceptions are captured. + /// + /// **Note:** + /// - iOS: Not supported + /// - Android: Java/Kotlin exceptions only (no native C/C++ crashes) + /// - Android: No stacktrace demangling for minified builds + /// + /// Default: true + var captureNativeExceptions = false; + + /// Enable automatic capture of isolate errors + /// + /// Controls whether errors from the current isolate are captured. + /// This includes errors from the main isolate and any isolates spawned + /// without explicit error handling. + /// + /// **Note:** + /// - Flutter web: Not supported + /// + /// Default: true + var captureIsolateErrors = false; + Map toMap() { return { 'inAppIncludes': inAppIncludes, 'inAppExcludes': inAppExcludes, 'inAppByDefault': inAppByDefault, + 'captureFlutterErrors': captureFlutterErrors, + 'captureSilentFlutterErrors': captureSilentFlutterErrors, + 'capturePlatformDispatcherErrors': capturePlatformDispatcherErrors, + 'captureNativeExceptions': captureNativeExceptions, + 'captureIsolateErrors': captureIsolateErrors, }; } } diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index fb9c7360..75a25cd5 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -459,7 +459,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, ); -// Add timestamp from Flutter side (will be used and removed from native plugins) + // Add timestamp from Flutter side (will be used and removed from native plugins) final timestamp = DateTime.now().millisecondsSinceEpoch; final normalizedData = PropertyNormalizer.normalize(exceptionData.cast()); diff --git a/test/dart_exception_processor_test.dart b/test/dart_exception_processor_test.dart index e336f451..28adc7b0 100644 --- a/test/dart_exception_processor_test.dart +++ b/test/dart_exception_processor_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/error_tracking/dart_exception_processor.dart'; +import 'package:posthog_flutter/src/error_tracking/posthog_exception.dart'; void main() { group('DartExceptionProcessor', () { @@ -450,6 +451,115 @@ void main() { expect(gapFrame['in_app'], isFalse); expect(gapFrame['abs_path'], equals('')); }); + + test('processes PostHogException with different mechanism types', () { + final testCases = [ + {'mechanism': 'FlutterError', 'handled': false}, + {'mechanism': 'PlatformDispatcher', 'handled': false}, + {'mechanism': 'UncaughtExceptionHandler', 'handled': true}, + {'mechanism': 'custom_mechanism', 'handled': true}, + ]; + + for (final testCase in testCases) { + final originalError = StateError('Test error'); + final postHogException = PostHogException( + source: originalError, + mechanism: testCase['mechanism'] as String, + handled: testCase['handled'] as bool, + ); + + final result = DartExceptionProcessor.processException( + error: postHogException, + stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), + ); + + final exceptionData = + (result['\$exception_list'] as List).first as Map; + + expect( + exceptionData['mechanism']['type'], equals(testCase['mechanism'])); + expect( + exceptionData['mechanism']['handled'], equals(testCase['handled'])); + expect(exceptionData['type'], equals('StateError')); + } + }); + + test( + 'uses original error for stack trace processing when wrapped in PostHogException', + () { + // Create an Error (not Exception) so it has a built-in stackTrace + late Error originalError; + + try { + throw StateError('Original error with stack trace'); + } catch (error) { + originalError = error as Error; + } + + // Wrap in PostHogException + final postHogException = PostHogException( + source: originalError, + mechanism: 'test_mechanism', + handled: true, + ); + + // Process without providing external stack trace - should use original error's stackTrace + final result = DartExceptionProcessor.processException( + error: postHogException, + // No stackTrace provided - should extract from original error + ); + + final exceptionData = + (result['\$exception_list'] as List).first as Map; + + // Verify it used the original error for processing + expect(exceptionData['type'], equals('StateError')); + expect(exceptionData['value'], + equals('Bad state: Original error with stack trace')); + expect(exceptionData['mechanism']['type'], equals('test_mechanism')); + expect(exceptionData['mechanism']['handled'], equals(true)); + + // Should have stacktrace frames from the original error + expect(exceptionData['stacktrace'], isNotNull); + expect(exceptionData['stacktrace']['frames'], isA()); + expect( + (exceptionData['stacktrace']['frames'] as List).isNotEmpty, isTrue); + }); + + test('processes original error type correctly when wrapped', () { + final testErrorTypes = [ + Exception('Test exception'), + StateError('State error'), + ArgumentError('Argument error'), + FormatException('Format error'), + RangeError('Range error'), + ]; + + for (final originalError in testErrorTypes) { + final postHogException = PostHogException( + source: originalError, + mechanism: 'test_mechanism', + ); + + final result = DartExceptionProcessor.processException( + error: postHogException, + stackTrace: StackTrace.fromString('#0 test (test.dart:1:1)'), + ); + + final exceptionData = + (result['\$exception_list'] as List).first as Map; + + // Should extract type from original error, not PostHogException + final expectedType = originalError.runtimeType.toString(); + expect(exceptionData['type'], equals(expectedType)); + + // Should use original error's toString for message + expect(exceptionData['value'], equals(originalError.toString())); + + // But mechanism should come from wrapper + expect(exceptionData['mechanism']['type'], equals('test_mechanism')); + } + }); }); } diff --git a/test/posthog_flutter_platform_interface_fake.dart b/test/posthog_flutter_platform_interface_fake.dart index fd679082..3c459db2 100644 --- a/test/posthog_flutter_platform_interface_fake.dart +++ b/test/posthog_flutter_platform_interface_fake.dart @@ -1,7 +1,21 @@ import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; +/// Captured exception call data +class CapturedExceptionCall { + final Object error; + final StackTrace? stackTrace; + final Map? properties; + + CapturedExceptionCall({ + required this.error, + this.stackTrace, + this.properties, + }); +} + class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { String? screenName; + final List capturedExceptions = []; @override Future screen({ @@ -10,4 +24,17 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { }) async { this.screenName = screenName; } + + @override + Future captureException({ + required Object error, + StackTrace? stackTrace, + Map? properties, + }) async { + capturedExceptions.add(CapturedExceptionCall( + error: error, + stackTrace: stackTrace, + properties: properties, + )); + } } From 8a6f1486e5206bb5579c2b65107f9690dd10f34f Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Wed, 5 Nov 2025 15:22:41 +0200 Subject: [PATCH 33/34] Update version --- CHANGELOG.md | 2 ++ android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt | 2 +- ios/Classes/PostHogFlutterVersion.swift | 2 +- pubspec.yaml | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e88dcbcf..dd4feb46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +## 5.9.0 + - feat: add autocapture exceptions ([#214](https://github.com/PostHog/posthog-flutter/pull/214)) - **Limitations**: - No Flutter web support diff --git a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt index 52a9feb8..2e6d5ffe 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PostHogVersion.kt @@ -1,3 +1,3 @@ package com.posthog.flutter -internal val postHogVersion = "5.8.0" +internal val postHogVersion = "5.9.0" diff --git a/ios/Classes/PostHogFlutterVersion.swift b/ios/Classes/PostHogFlutterVersion.swift index 7a3fb63a..e5af7fcc 100644 --- a/ios/Classes/PostHogFlutterVersion.swift +++ b/ios/Classes/PostHogFlutterVersion.swift @@ -8,4 +8,4 @@ import Foundation // This property is internal only -let postHogFlutterVersion = "5.8.0" +let postHogFlutterVersion = "5.9.0" diff --git a/pubspec.yaml b/pubspec.yaml index 69dbe0f3..18775a6b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: posthog_flutter description: Flutter implementation of PostHog client for iOS, Android and Web -version: 5.8.0 +version: 5.9.0 homepage: https://www.posthog.com repository: https://github.com/posthog/posthog-flutter issue_tracker: https://github.com/posthog/posthog-flutter/issues From f8ea523a129d4e7f6b38142d5e20bc237c58fb85 Mon Sep 17 00:00:00 2001 From: Ioannis J Date: Sat, 8 Nov 2025 03:03:33 +0200 Subject: [PATCH 34/34] fix: code comments (#218) --- lib/src/posthog_config.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index f614044b..0d008c88 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -164,7 +164,7 @@ class PostHogErrorTrackingConfig { /// **Note:** /// - Flutter web: Not supported /// - /// Default: true (when autocapture is enabled) + /// Default: false var captureFlutterErrors = false; /// Enable capturing of silent Flutter errors @@ -184,7 +184,7 @@ class PostHogErrorTrackingConfig { /// **Note:** /// - Flutter web: Not supported /// - /// Default: true + /// Default: false var capturePlatformDispatcherErrors = false; /// Enable automatic capture of exceptions in the native SDKs (Android only for now) @@ -196,7 +196,7 @@ class PostHogErrorTrackingConfig { /// - Android: Java/Kotlin exceptions only (no native C/C++ crashes) /// - Android: No stacktrace demangling for minified builds /// - /// Default: true + /// Default: false var captureNativeExceptions = false; /// Enable automatic capture of isolate errors @@ -208,7 +208,7 @@ class PostHogErrorTrackingConfig { /// **Note:** /// - Flutter web: Not supported /// - /// Default: true + /// Default: false var captureIsolateErrors = false; Map toMap() {