From b21b234b9a2be0be4f10ddae2add9b55c70fd1dd Mon Sep 17 00:00:00 2001 From: JulianAlcala Date: Tue, 21 Apr 2026 19:33:54 +0200 Subject: [PATCH] feat(sf_tracking): consent-aware crashlytics wrapper --- apps/mobile_app/lib/core/firebase_init.dart | 9 +- packages/sf_tracking/lib/sf_tracking.dart | 2 + .../lib/src/clients/crashlytics_service.dart | 106 ++++++++++++++++++ .../crashlytics_service_provider.dart | 10 ++ packages/sf_tracking/pubspec.yaml | 2 + .../clients/crashlytics_service_test.dart | 48 ++++++++ 6 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 packages/sf_tracking/lib/src/clients/crashlytics_service.dart create mode 100644 packages/sf_tracking/lib/src/providers/crashlytics_service_provider.dart create mode 100644 packages/sf_tracking/test/clients/crashlytics_service_test.dart diff --git a/apps/mobile_app/lib/core/firebase_init.dart b/apps/mobile_app/lib/core/firebase_init.dart index 5758b495..131e5132 100644 --- a/apps/mobile_app/lib/core/firebase_init.dart +++ b/apps/mobile_app/lib/core/firebase_init.dart @@ -1,9 +1,9 @@ import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_performance/firebase_performance.dart'; import 'package:firebase_remote_config/firebase_remote_config.dart'; import 'package:flutter/foundation.dart'; +import 'package:sf_tracking/sf_tracking.dart'; import '../config/env/environment_enum.dart'; import '../firebase_options_dev.dart' as dev_options; @@ -25,9 +25,12 @@ Future setupFirebase(EnvironmentEnum env) async { await Firebase.initializeApp(options: options); // Report crashes in ALL builds (debug + release) so we catch issues during testing too. - FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError; + // TODO(gdpr): wire `enabled` to real consent once the fix in backlog lands. + final crashlytics = FirebaseCrashlyticsService(enabled: true); + FlutterError.onError = (details) => + crashlytics.recordFlutterError(details, fatal: true); PlatformDispatcher.instance.onError = (error, stack) { - FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); + crashlytics.recordError(error, stack, fatal: true); return true; }; diff --git a/packages/sf_tracking/lib/sf_tracking.dart b/packages/sf_tracking/lib/sf_tracking.dart index dde8b017..02e3fb1d 100644 --- a/packages/sf_tracking/lib/sf_tracking.dart +++ b/packages/sf_tracking/lib/sf_tracking.dart @@ -1,6 +1,7 @@ /// SaveFamily tracking & analytics package. library; +export 'src/clients/crashlytics_service.dart'; export 'src/clients/debug_tracking_client.dart'; export 'src/clients/firebase_tracking_client.dart'; export 'src/mixins/account_tracking.dart'; @@ -16,6 +17,7 @@ export 'src/mixins/settings_tracking.dart'; export 'src/mixins/support_tracking.dart'; export 'src/navigation/navigation_tracking.dart'; export 'src/navigation/sf_router_listener.dart'; +export 'src/providers/crashlytics_service_provider.dart'; export 'src/providers/sf_tracking_provider.dart'; export 'src/sf_tracking_repository.dart'; export 'src/tracking.dart'; diff --git a/packages/sf_tracking/lib/src/clients/crashlytics_service.dart b/packages/sf_tracking/lib/src/clients/crashlytics_service.dart new file mode 100644 index 00000000..224ad57e --- /dev/null +++ b/packages/sf_tracking/lib/src/clients/crashlytics_service.dart @@ -0,0 +1,106 @@ +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/foundation.dart'; + +abstract class CrashlyticsService { + Future recordError( + Object exception, + StackTrace? stackTrace, { + bool fatal = false, + Iterable information = const [], + String? reason, + }); + + Future recordFlutterError( + FlutterErrorDetails details, { + bool fatal = false, + }); + + Future log(String message); + + Future setUserIdentifier(String identifier); + + Future setCustomKey(String key, Object value); +} + +class FirebaseCrashlyticsService implements CrashlyticsService { + FirebaseCrashlyticsService({required this.enabled}); + + final bool enabled; + + @override + Future recordError( + Object exception, + StackTrace? stackTrace, { + bool fatal = false, + Iterable information = const [], + String? reason, + }) async { + if (!enabled) return; + await FirebaseCrashlytics.instance.recordError( + exception, + stackTrace, + fatal: fatal, + information: information, + reason: reason, + ); + } + + @override + Future recordFlutterError( + FlutterErrorDetails details, { + bool fatal = false, + }) async { + if (!enabled) return; + if (fatal) { + await FirebaseCrashlytics.instance.recordFlutterFatalError(details); + } else { + await FirebaseCrashlytics.instance.recordFlutterError(details); + } + } + + @override + Future log(String message) async { + if (!enabled) return; + await FirebaseCrashlytics.instance.log(message); + } + + @override + Future setUserIdentifier(String identifier) async { + if (!enabled) return; + await FirebaseCrashlytics.instance.setUserIdentifier(identifier); + } + + @override + Future setCustomKey(String key, Object value) async { + if (!enabled) return; + await FirebaseCrashlytics.instance.setCustomKey(key, value); + } +} + +class NoopCrashlyticsService implements CrashlyticsService { + const NoopCrashlyticsService(); + + @override + Future recordError( + Object exception, + StackTrace? stackTrace, { + bool fatal = false, + Iterable information = const [], + String? reason, + }) async {} + + @override + Future recordFlutterError( + FlutterErrorDetails details, { + bool fatal = false, + }) async {} + + @override + Future log(String message) async {} + + @override + Future setUserIdentifier(String identifier) async {} + + @override + Future setCustomKey(String key, Object value) async {} +} diff --git a/packages/sf_tracking/lib/src/providers/crashlytics_service_provider.dart b/packages/sf_tracking/lib/src/providers/crashlytics_service_provider.dart new file mode 100644 index 00000000..e7fe4cd9 --- /dev/null +++ b/packages/sf_tracking/lib/src/providers/crashlytics_service_provider.dart @@ -0,0 +1,10 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../clients/crashlytics_service.dart'; + +// TODO(gdpr): wire `enabled` to the real user consent once the fix in backlog +// lands (see sf-legacy-migration-checklist backlog). Hardcoded `true` today +// matches the pre-refactor behaviour. +final crashlyticsServiceProvider = Provider((ref) { + return FirebaseCrashlyticsService(enabled: true); +}); diff --git a/packages/sf_tracking/pubspec.yaml b/packages/sf_tracking/pubspec.yaml index 3d356a0d..bcd57275 100644 --- a/packages/sf_tracking/pubspec.yaml +++ b/packages/sf_tracking/pubspec.yaml @@ -12,11 +12,13 @@ dependencies: flutter: sdk: flutter firebase_analytics: ^12.2.0 + firebase_crashlytics: ^5.1.0 flutter_riverpod: ^3.0.3 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^5.0.0 + mocktail: ^1.0.4 flutter: diff --git a/packages/sf_tracking/test/clients/crashlytics_service_test.dart b/packages/sf_tracking/test/clients/crashlytics_service_test.dart new file mode 100644 index 00000000..47ce8696 --- /dev/null +++ b/packages/sf_tracking/test/clients/crashlytics_service_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sf_tracking/sf_tracking.dart'; + +void main() { + group('NoopCrashlyticsService', () { + test('recordError completes without side effects', () async { + const service = NoopCrashlyticsService(); + await service.recordError(Exception('x'), StackTrace.current); + }); + + test('recordFlutterError completes without side effects', () async { + const service = NoopCrashlyticsService(); + await service.recordFlutterError( + FlutterErrorDetails(exception: Exception('x')), + ); + }); + + test('log / setUserIdentifier / setCustomKey are no-ops', () async { + const service = NoopCrashlyticsService(); + await service.log('hello'); + await service.setUserIdentifier('u1'); + await service.setCustomKey('k', 'v'); + }); + }); + + group('FirebaseCrashlyticsService with enabled=false', () { + test('recordError is a no-op when disabled', () async { + final service = FirebaseCrashlyticsService(enabled: false); + await service.recordError(Exception('x'), StackTrace.current); + }); + + test('log is a no-op when disabled', () async { + final service = FirebaseCrashlyticsService(enabled: false); + await service.log('hello'); + }); + + test('setUserIdentifier is a no-op when disabled', () async { + final service = FirebaseCrashlyticsService(enabled: false); + await service.setUserIdentifier('u1'); + }); + + test('setCustomKey is a no-op when disabled', () async { + final service = FirebaseCrashlyticsService(enabled: false); + await service.setCustomKey('k', 'v'); + }); + }); +}