diff --git a/CHANGELOG.md b/CHANGELOG.md index 25eaa14..f33ff33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ #### 2026-05-04 - Jump to top action in feed & profile screens. +- Firebase Crashlytics integration for crash reporting and analytics. ### Fixed diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0df05c6..e78b577 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -4,6 +4,7 @@ plugins { // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") id("com.google.gms.google-services") + id("com.google.firebase.crashlytics") } android { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index aea81d5..a3b6077 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ android:label="Lazurite" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> + $(FLUTTER_BUILD_NAME) CFBundleSignature ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + FirebaseCrashlyticsCollectionEnabled + + LSRequiresIPhoneOS + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/lib/core/crash_reporting/crash_reporting_consent_gate.dart b/lib/core/crash_reporting/crash_reporting_consent_gate.dart new file mode 100644 index 0000000..87ff9f9 --- /dev/null +++ b/lib/core/crash_reporting/crash_reporting_consent_gate.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_state.dart'; + +class CrashReportingConsentGate extends StatefulWidget { + const CrashReportingConsentGate({required this.child, required this.crashReportingService, super.key}); + + final Widget child; + final CrashReportingService crashReportingService; + + @override + State createState() => _CrashReportingConsentGateState(); +} + +class _CrashReportingConsentGateState extends State { + bool _dialogShownThisRun = false; + bool _dialogInFlight = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _scheduleConsentPromptCheck()); + } + + void _scheduleConsentPromptCheck() { + if (!mounted || _dialogInFlight || _dialogShownThisRun) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_maybePromptForConsent())); + } + + bool _shouldPrompt() { + final authState = context.read().state; + if (!authState.isAuthenticated) { + return false; + } + final settingsState = context.read().state; + return !settingsState.crashReportingConsentPrompted; + } + + Future _maybePromptForConsent() async { + if (!mounted || _dialogInFlight || _dialogShownThisRun || !_shouldPrompt()) { + return; + } + + _dialogInFlight = true; + try { + final shouldEnable = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return PopScope( + canPop: false, + child: AlertDialog( + title: const Text('Help Improve Stability?'), + content: const Text( + 'Would you like to enable crash and error reporting? ' + 'This helps identify and fix app stability issues.', + ), + actions: [ + TextButton(onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Not now')), + FilledButton(onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Enable')), + ], + ), + ); + }, + ); + + final enabled = shouldEnable == true; + if (!mounted) { + return; + } + + final settingsCubit = context.read(); + await settingsCubit.setCrashReportingConsentPrompted(true); + await settingsCubit.setCrashReportingEnabled(enabled); + + await widget.crashReportingService.setCollectionEnabled(enabled); + if (enabled) { + await widget.crashReportingService.sendUnsentReports(); + } else { + await widget.crashReportingService.deleteUnsentReports(); + } + + _dialogShownThisRun = true; + } catch (error, stackTrace) { + log.w('Crash reporting consent prompt failed', error: error, stackTrace: stackTrace); + } finally { + _dialogInFlight = false; + } + } + + @override + Widget build(BuildContext context) => MultiBlocListener( + listeners: [ + BlocListener(listener: (_, _) => _scheduleConsentPromptCheck()), + BlocListener(listener: (_, _) => _scheduleConsentPromptCheck()), + ], + child: widget.child, + ); +} diff --git a/lib/core/crash_reporting/crash_reporting_service.dart b/lib/core/crash_reporting/crash_reporting_service.dart new file mode 100644 index 0000000..0cc47a3 --- /dev/null +++ b/lib/core/crash_reporting/crash_reporting_service.dart @@ -0,0 +1,80 @@ +import 'dart:async'; + +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/foundation.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; + +abstract class CrashReportingService { + void recordFlutterFatalError(FlutterErrorDetails details); + + Future recordError(Object error, StackTrace stackTrace, {bool fatal}); + + Future setCollectionEnabled(bool enabled); + + Future sendUnsentReports(); + + Future deleteUnsentReports(); + + void crash(); +} + +class FirebaseCrashReportingService implements CrashReportingService { + FirebaseCrashReportingService({FirebaseCrashlytics? crashlytics}) + : _crashlytics = crashlytics ?? FirebaseCrashlytics.instance; + + final FirebaseCrashlytics _crashlytics; + + @override + void recordFlutterFatalError(FlutterErrorDetails details) { + try { + unawaited( + _crashlytics.recordFlutterFatalError(details).catchError((Object error, StackTrace stackTrace) { + log.w('Unable to record Flutter fatal error in Crashlytics', error: error, stackTrace: stackTrace); + }), + ); + } catch (error, stackTrace) { + log.w('Unable to record Flutter fatal error in Crashlytics', error: error, stackTrace: stackTrace); + } + } + + @override + Future recordError(Object error, StackTrace stackTrace, {bool fatal = false}) async { + try { + await _crashlytics.recordError(error, stackTrace, fatal: fatal); + } catch (recordError, recordStackTrace) { + log.w('Unable to record error in Crashlytics', error: recordError, stackTrace: recordStackTrace); + } + } + + @override + Future setCollectionEnabled(bool enabled) async { + try { + await _crashlytics.setCrashlyticsCollectionEnabled(enabled); + } catch (error, stackTrace) { + log.w('Unable to set Crashlytics collection state', error: error, stackTrace: stackTrace); + } + } + + @override + Future sendUnsentReports() async { + try { + await _crashlytics.sendUnsentReports(); + } catch (error, stackTrace) { + log.w('Unable to send unsent Crashlytics reports', error: error, stackTrace: stackTrace); + } + } + + @override + Future deleteUnsentReports() async { + try { + await _crashlytics.deleteUnsentReports(); + } catch (error, stackTrace) { + log.w('Unable to delete unsent Crashlytics reports', error: error, stackTrace: stackTrace); + } + } + + @override + void crash() { + _crashlytics.crash(); + } +} diff --git a/lib/core/router/app_shell.dart b/lib/core/router/app_shell.dart index 3ec9d92..f3c8252 100644 --- a/lib/core/router/app_shell.dart +++ b/lib/core/router/app_shell.dart @@ -5,6 +5,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/crash_reporting/crash_reporting_consent_gate.dart'; +import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; import 'package:lazurite/core/theme/animation_tokens.dart'; import 'package:lazurite/core/theme/animation_utils.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; @@ -92,6 +94,12 @@ class _AppShellState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); + CrashReportingService? crashReportingService; + try { + crashReportingService = context.read(); + } catch (_) { + crashReportingService = null; + } return AppShellScope( openMenu: _openMenu, child: PopScope( @@ -105,7 +113,9 @@ class _AppShellState extends State { child: Scaffold( key: AppShell.scaffoldKey, drawer: _AppMenu(navigationShell: widget.navigationShell, rootContext: context), - body: widget.navigationShell, + body: crashReportingService == null + ? widget.navigationShell + : CrashReportingConsentGate(crashReportingService: crashReportingService, child: widget.navigationShell), bottomNavigationBar: Container( decoration: BoxDecoration( color: theme.colorScheme.surface.withValues(alpha: 0.92), @@ -496,20 +506,17 @@ class _MenuSectionLabel extends StatelessWidget { final String label; @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: Text( - label.toUpperCase(), - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w700, - letterSpacing: 0.8, - ), + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: Text( + label.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, ), - ); - } + ), + ); } class _MenuProfileAvatar extends StatefulWidget { diff --git a/lib/features/settings/bloc/settings_cubit.dart b/lib/features/settings/bloc/settings_cubit.dart index 0d7dfce..5a596ea 100644 --- a/lib/features/settings/bloc/settings_cubit.dart +++ b/lib/features/settings/bloc/settings_cubit.dart @@ -56,6 +56,8 @@ class SettingsCubit extends Cubit { static const String _keyAppViewProvider = 'appview_provider'; static const String _keyCrossProviderFallbackEnabled = 'cross_provider_fallback_enabled'; static const String _keySlingshotIdentityFallbackEnabled = 'slingshot_identity_fallback_enabled'; + static const String _keyCrashReportingEnabled = 'crash_reporting_enabled'; + static const String _keyCrashReportingConsentPrompted = 'crash_reporting_consent_prompted'; Future loadSettings() async { final paletteStr = await database.getSetting(_keyThemePalette); @@ -74,6 +76,8 @@ class SettingsCubit extends Cubit { final appViewProviderStr = await database.getSetting(_keyAppViewProvider); final crossProviderFallbackEnabledStr = await database.getSetting(_keyCrossProviderFallbackEnabled); final slingshotIdentityFallbackEnabledStr = await database.getSetting(_keySlingshotIdentityFallbackEnabled); + final crashReportingEnabledStr = await database.getSetting(_keyCrashReportingEnabled); + final crashReportingConsentPromptedStr = await database.getSetting(_keyCrashReportingConsentPrompted); final resolvedTypeaheadProvider = _supportedTypeaheadProviders.contains(typeaheadProviderStr) ? typeaheadProviderStr! : _defaultTypeaheadProvider; @@ -96,6 +100,8 @@ class SettingsCubit extends Cubit { appViewProvider: resolvedAppViewProvider, crossProviderFallbackEnabled: crossProviderFallbackEnabledStr == 'true', slingshotIdentityFallbackEnabled: slingshotIdentityFallbackEnabledStr == 'true', + crashReportingEnabled: crashReportingEnabledStr == 'true', + crashReportingConsentPrompted: crashReportingConsentPromptedStr == 'true', ), ); } @@ -245,4 +251,14 @@ class SettingsCubit extends Cubit { await database.setSetting(_keySlingshotIdentityFallbackEnabled, enabled.toString()); emit(state.copyWith(slingshotIdentityFallbackEnabled: enabled)); } + + Future setCrashReportingEnabled(bool enabled) async { + await database.setSetting(_keyCrashReportingEnabled, enabled.toString()); + emit(state.copyWith(crashReportingEnabled: enabled)); + } + + Future setCrashReportingConsentPrompted(bool prompted) async { + await database.setSetting(_keyCrashReportingConsentPrompted, prompted.toString()); + emit(state.copyWith(crashReportingConsentPrompted: prompted)); + } } diff --git a/lib/features/settings/bloc/settings_state.dart b/lib/features/settings/bloc/settings_state.dart index 3498658..5b99080 100644 --- a/lib/features/settings/bloc/settings_state.dart +++ b/lib/features/settings/bloc/settings_state.dart @@ -22,6 +22,8 @@ class SettingsState extends Equatable { this.appViewProvider = 'bluesky', this.crossProviderFallbackEnabled = false, this.slingshotIdentityFallbackEnabled = false, + this.crashReportingEnabled = false, + this.crashReportingConsentPrompted = false, this.routingEpoch = 0, this.appViewHealthSummary, this.appViewHealthCheckedAt, @@ -60,6 +62,12 @@ class SettingsState extends Equatable { /// Enables Slingshot identity fallback for degraded handle resolution. final bool slingshotIdentityFallbackEnabled; + /// Whether crash/error reports can be sent to Crashlytics. + final bool crashReportingEnabled; + + /// Whether the one-time crash reporting consent prompt has already been shown. + final bool crashReportingConsentPrompted; + /// In-memory epoch incremented when routing state is soft-reset. final int routingEpoch; @@ -94,6 +102,8 @@ class SettingsState extends Equatable { String? appViewProvider, bool? crossProviderFallbackEnabled, bool? slingshotIdentityFallbackEnabled, + bool? crashReportingEnabled, + bool? crashReportingConsentPrompted, int? routingEpoch, Object? appViewHealthSummary = _threadAutoCollapseDepthUnset, Object? appViewHealthCheckedAt = _threadAutoCollapseDepthUnset, @@ -119,6 +129,8 @@ class SettingsState extends Equatable { appViewProvider: appViewProvider ?? this.appViewProvider, crossProviderFallbackEnabled: crossProviderFallbackEnabled ?? this.crossProviderFallbackEnabled, slingshotIdentityFallbackEnabled: slingshotIdentityFallbackEnabled ?? this.slingshotIdentityFallbackEnabled, + crashReportingEnabled: crashReportingEnabled ?? this.crashReportingEnabled, + crashReportingConsentPrompted: crashReportingConsentPrompted ?? this.crashReportingConsentPrompted, routingEpoch: routingEpoch ?? this.routingEpoch, appViewHealthSummary: identical(appViewHealthSummary, _threadAutoCollapseDepthUnset) ? this.appViewHealthSummary @@ -153,6 +165,8 @@ class SettingsState extends Equatable { appViewProvider, crossProviderFallbackEnabled, slingshotIdentityFallbackEnabled, + crashReportingEnabled, + crashReportingConsentPrompted, routingEpoch, appViewHealthSummary, appViewHealthCheckedAt, diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 5c69c47..bf9a15d 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/network/atproto_host_resolver.dart'; import 'package:lazurite/core/router/app_shell.dart'; @@ -112,7 +113,7 @@ class SettingsScreen extends StatelessWidget { _buildSectionHeader(context, 'Advanced'), _buildAdvancedSettings(context), const SizedBox(height: 24), - if (!kReleaseMode) ...[ + if (!kReleaseMode || kDebugMode) ...[ _buildSectionHeader(context, 'Developer'), _buildDeveloperSettings(context), const SizedBox(height: 24), @@ -359,6 +360,7 @@ class SettingsScreen extends StatelessWidget { Widget _buildDeveloperSettings(BuildContext context) { final settingsCubit = context.read(); + final crashReportingService = _readCrashReportingServiceOrNull(context); return BlocBuilder( builder: (context, state) { @@ -371,17 +373,37 @@ class SettingsScreen extends StatelessWidget { ), color: theme.cardColor, ), - child: _SettingsTile( - icon: Icons.cloud_off_outlined, - title: 'Go Offline', - subtitle: 'Turn off online connectivity', - trailing: Switch.adaptive(value: state.simulateOffline, onChanged: settingsCubit.setSimulateOffline), + child: Column( + children: [ + _SettingsTile( + icon: Icons.cloud_off_outlined, + title: 'Go Offline', + subtitle: 'Turn off online connectivity', + trailing: Switch.adaptive(value: state.simulateOffline, onChanged: settingsCubit.setSimulateOffline), + ), + const Divider(height: 1), + _SettingsTile( + icon: Icons.bug_report_outlined, + title: 'Crashlytics Test Crash', + subtitle: 'Intentionally crash to validate Crashlytics reports', + trailing: const Icon(Icons.warning_amber_rounded), + onTap: crashReportingService?.crash, + ), + ], ), ); }, ); } + CrashReportingService? _readCrashReportingServiceOrNull(BuildContext context) { + try { + return context.read(); + } on ProviderNotFoundException { + return null; + } + } + Widget _buildAdvancedSettings(BuildContext context) { final settingsCubit = context.read(); final theme = Theme.of(context); @@ -446,6 +468,18 @@ class SettingsScreen extends StatelessWidget { ), ), const Divider(height: 1), + _SettingsTile( + icon: Icons.bug_report_outlined, + title: 'Crash Reporting', + subtitle: state.crashReportingEnabled + ? 'Enabled. Crash and error reports are sent to improve stability.' + : 'Disabled. Crash and error reports are not sent.', + trailing: Switch.adaptive( + value: state.crashReportingEnabled, + onChanged: (enabled) => unawaited(_handleCrashReportingToggle(context, enabled)), + ), + ), + const Divider(height: 1), const _SettingsTile( icon: Icons.monitor_heart_outlined, title: 'Provider Diagnostics', @@ -522,6 +556,19 @@ class SettingsScreen extends StatelessWidget { await context.read().setAppViewProvider(selectedProvider); } + + Future _handleCrashReportingToggle(BuildContext context, bool enabled) async { + final settingsCubit = context.read(); + final crashReportingService = context.read(); + await settingsCubit.setCrashReportingEnabled(enabled); + await settingsCubit.setCrashReportingConsentPrompted(true); + await crashReportingService.setCollectionEnabled(enabled); + if (enabled) { + await crashReportingService.sendUnsentReports(); + return; + } + await crashReportingService.deleteUnsentReports(); + } } class _ModerationSettingsPreview extends StatefulWidget { diff --git a/lib/main.dart b/lib/main.dart index 0116d9c..a5cddb0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,16 @@ import 'dart:async'; +import 'dart:ui'; import 'package:bluesky/bluesky.dart'; import 'package:bluesky/bluesky_chat.dart'; +import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/bootstrap/auth_bootstrap.dart'; import 'package:lazurite/core/cache/offline_cache_policy.dart'; +import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/embedding/embedding_service.dart'; import 'package:lazurite/core/logging/app_logger.dart'; @@ -73,6 +76,21 @@ Future main() async { imageCache.maximumSizeBytes = OfflineCachePolicy.imageMemoryByteLimit; await log.initialize(); + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp(); + } + + final crashReportingService = FirebaseCrashReportingService(); + final previousFlutterErrorHandler = FlutterError.onError; + FlutterError.onError = (details) { + previousFlutterErrorHandler?.call(details); + crashReportingService.recordFlutterFatalError(details); + }; + PlatformDispatcher.instance.onError = (error, stackTrace) { + unawaited(crashReportingService.recordError(error, stackTrace, fatal: true)); + return true; + }; + await PostScheduler.initialize(); FirebaseMessaging.onBackgroundMessage(notificationFirebaseMessagingBackgroundHandler); await NotificationBackgroundScheduler.ensureScheduled(); @@ -99,6 +117,7 @@ Future main() async { ); final authRepository = authBootstrap.authRepository; final restoredSession = authBootstrap.restoredSession; + await crashReportingService.setCollectionEnabled(settingsCubit.state.crashReportingEnabled); final authBloc = AuthBloc( authRepository: authRepository, initialState: restoredSession != null @@ -127,19 +146,27 @@ Future main() async { log.i('AppLogger: App started'); - runApp( - LazuriteApp.from( - authBloc, - database, - appViewFallbackService, - objectBoxStore, - embeddingService, - settingsCubit, - connectivityCubit, - accountSwitcherCubit, - localNotificationAdapter, - pushRegistrationService, - ), + runZonedGuarded( + () { + runApp( + LazuriteApp.from( + authBloc, + database, + appViewFallbackService, + objectBoxStore, + embeddingService, + settingsCubit, + connectivityCubit, + accountSwitcherCubit, + localNotificationAdapter, + pushRegistrationService, + crashReportingService, + ), + ); + }, + (error, stackTrace) { + unawaited(crashReportingService.recordError(error, stackTrace, fatal: true)); + }, ); } @@ -156,6 +183,7 @@ class LazuriteApp extends StatefulWidget { required this.accountSwitcherCubit, required this.localNotificationAdapter, required this.pushRegistrationService, + required this.crashReportingService, }); final AuthBloc authBloc; @@ -168,6 +196,7 @@ class LazuriteApp extends StatefulWidget { final AccountSwitcherCubit accountSwitcherCubit; final LocalNotificationAdapter localNotificationAdapter; final PushRegistrationService pushRegistrationService; + final CrashReportingService crashReportingService; /// factory constructor with positional params static LazuriteApp from( @@ -181,6 +210,7 @@ class LazuriteApp extends StatefulWidget { AccountSwitcherCubit accountSwitcherCubit, LocalNotificationAdapter localNotificationAdapter, PushRegistrationService pushRegistrationService, + CrashReportingService crashReportingService, ) => LazuriteApp( authBloc: authBloc, database: database, @@ -192,6 +222,7 @@ class LazuriteApp extends StatefulWidget { accountSwitcherCubit: accountSwitcherCubit, localNotificationAdapter: localNotificationAdapter, pushRegistrationService: pushRegistrationService, + crashReportingService: crashReportingService, ); @override @@ -340,279 +371,288 @@ class _LazuriteAppState extends State { @override Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider.value(value: widget.authBloc), - BlocProvider.value(value: widget.settingsCubit), - BlocProvider.value(value: widget.connectivityCubit), - BlocProvider.value(value: widget.accountSwitcherCubit), - ], - child: BlocBuilder( - builder: (context, authState) { - final bluesky = _createBluesky(authState); - final blueskyChat = _createBlueskyChat(authState); - final appShell = BlocBuilder( - builder: (context, settingsState) { - final themeMode = settingsState.useSystemTheme - ? ThemeMode.system - : (settingsState.themeVariant == AppThemeVariant.light ? ThemeMode.light : ThemeMode.dark); - - final lightTheme = AppTheme.getTheme(settingsState.themePalette, AppThemeVariant.light); - final darkTheme = AppTheme.getTheme(settingsState.themePalette, AppThemeVariant.dark); - - return MaterialApp.router( - key: ValueKey('router-$_routerSessionKey-$_routerGeneration'), - title: 'Lazurite', - debugShowCheckedModeBanner: false, - theme: lightTheme, - darkTheme: darkTheme, - themeMode: themeMode, - routerConfig: _router, - builder: (context, child) => GlobalTapOutsideUnfocus( - child: Stack( - children: [ - ConnectivityBannerHost(child: child ?? const SizedBox.shrink()), - if (_isSoftRestarting) - const ColoredBox( - color: Color(0xC0000000), - child: Center( - child: Card( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2.5)), - SizedBox(width: 12), - Text('Applying provider change...'), - ], + return RepositoryProvider.value( + value: widget.crashReportingService, + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: widget.authBloc), + BlocProvider.value(value: widget.settingsCubit), + BlocProvider.value(value: widget.connectivityCubit), + BlocProvider.value(value: widget.accountSwitcherCubit), + ], + child: BlocBuilder( + builder: (context, authState) { + final bluesky = _createBluesky(authState); + final blueskyChat = _createBlueskyChat(authState); + final appShell = BlocBuilder( + builder: (context, settingsState) { + final themeMode = settingsState.useSystemTheme + ? ThemeMode.system + : (settingsState.themeVariant == AppThemeVariant.light ? ThemeMode.light : ThemeMode.dark); + + final lightTheme = AppTheme.getTheme(settingsState.themePalette, AppThemeVariant.light); + final darkTheme = AppTheme.getTheme(settingsState.themePalette, AppThemeVariant.dark); + + return MaterialApp.router( + key: ValueKey('router-$_routerSessionKey-$_routerGeneration'), + title: 'Lazurite', + debugShowCheckedModeBanner: false, + theme: lightTheme, + darkTheme: darkTheme, + themeMode: themeMode, + routerConfig: _router, + builder: (context, child) => GlobalTapOutsideUnfocus( + child: Stack( + children: [ + ConnectivityBannerHost(child: child ?? const SizedBox.shrink()), + if (_isSoftRestarting) + const ColoredBox( + color: Color(0xC0000000), + child: Center( + child: Card( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + SizedBox(width: 12), + Text('Applying provider change...'), + ], + ), ), ), ), ), - ), - ], + ], + ), ), - ), - ); - }, - ); - - if (bluesky == null || blueskyChat == null) { - return appShell; - } - - final accountDid = authState.tokens?.did ?? ''; - - return KeyedSubtree( - key: ValueKey('account-$accountDid-routing-${context.read().state.routingEpoch}'), - child: MultiRepositoryProvider( - providers: [ - RepositoryProvider( - create: (_) { - final settingsCubit = context.read(); - final moderationService = ModerationService( + ); + }, + ); + + if (bluesky == null || blueskyChat == null) { + return appShell; + } + + final accountDid = authState.tokens?.did ?? ''; + + return KeyedSubtree( + key: ValueKey('account-$accountDid-routing-${context.read().state.routingEpoch}'), + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider( + create: (_) { + final settingsCubit = context.read(); + final moderationService = ModerationService( + bluesky: bluesky, + database: widget.database, + accountDid: accountDid, + userDid: accountDid, + appViewProviderResolver: () => settingsCubit.state.appViewProvider, + ); + unawaited(moderationService.ensureInitialized()); + return moderationService; + }, + dispose: (moderationService) => moderationService.dispose(), + ), + RepositoryProvider( + create: (context) => FeedRepository( bluesky: bluesky, database: widget.database, accountDid: accountDid, - userDid: accountDid, - appViewProviderResolver: () => settingsCubit.state.appViewProvider, - ); - unawaited(moderationService.ensureInitialized()); - return moderationService; - }, - dispose: (moderationService) => moderationService.dispose(), - ), - RepositoryProvider( - create: (context) => FeedRepository( - bluesky: bluesky, - database: widget.database, - accountDid: accountDid, - moderationService: context.read(), - appViewProviderResolver: () => context.read().state.appViewProvider, - crossProviderFallbackEnabledResolver: () => - context.read().state.crossProviderFallbackEnabled, - appViewFallbackService: widget.appViewFallbackService, - routingEpoch: context.read().state.routingEpoch, - routingEpochResolver: () => context.read().state.routingEpoch, - ), - ), - RepositoryProvider( - create: (context) { - final settingsCubit = context.read(); - return SearchRepository( - bluesky: bluesky, moderationService: context.read(), - appViewProviderResolver: () => settingsCubit.state.appViewProvider, - crossProviderFallbackEnabledResolver: () => settingsCubit.state.crossProviderFallbackEnabled, - appViewFallbackService: widget.appViewFallbackService, - routingEpoch: settingsCubit.state.routingEpoch, - routingEpochResolver: () => settingsCubit.state.routingEpoch, - ); - }, - ), - RepositoryProvider( - create: (context) { - final settingsCubit = context.read(); - return TypeaheadRepository( - bluesky: bluesky, - providerResolver: () => settingsCubit.state.typeaheadProvider, - appViewProviderResolver: () => settingsCubit.state.appViewProvider, - moderationService: context.read(), - ); - }, - ), - RepositoryProvider( - create: (context) => ListRepository( - bluesky: bluesky, - moderationService: context.read(), - appViewProviderResolver: () => context.read().state.appViewProvider, - ), - ), - RepositoryProvider( - create: (context) { - final service = context.read(); - return ProfileRepository( - database: widget.database, - bluesky: bluesky, - moderationService: service, appViewProviderResolver: () => context.read().state.appViewProvider, - ); - }, - ), - RepositoryProvider( - create: (context) => NotificationRepository( - bluesky: bluesky, - moderationService: context.read(), - appViewProviderResolver: () => context.read().state.appViewProvider, - ), - ), - RepositoryProvider( - create: (context) => NotificationDomainService( - notificationRepository: context.read(), - database: widget.database, - accountDid: accountDid, - localNotificationAdapter: widget.localNotificationAdapter, - shouldSuppressLocalNotifications: _isAlertsRouteActive, - ), - ), - RepositoryProvider( - create: (context) => PostThreadRepository( - bluesky: bluesky, - database: widget.database, - accountDid: accountDid, - moderationService: context.read(), - appViewProviderResolver: () => context.read().state.appViewProvider, - ), - ), - RepositoryProvider( - create: (context) => StarterPackRepository( - bluesky: bluesky, - moderationService: context.read(), - appViewProviderResolver: () => context.read().state.appViewProvider, + crossProviderFallbackEnabledResolver: () => + context.read().state.crossProviderFallbackEnabled, + appViewFallbackService: widget.appViewFallbackService, + routingEpoch: context.read().state.routingEpoch, + routingEpochResolver: () => context.read().state.routingEpoch, + ), ), - ), - RepositoryProvider( - create: (context) => PostActionRepository( - bluesky: bluesky, - appViewProviderResolver: () => context.read().state.appViewProvider, + RepositoryProvider( + create: (context) { + final settingsCubit = context.read(); + return SearchRepository( + bluesky: bluesky, + moderationService: context.read(), + appViewProviderResolver: () => settingsCubit.state.appViewProvider, + crossProviderFallbackEnabledResolver: () => settingsCubit.state.crossProviderFallbackEnabled, + appViewFallbackService: widget.appViewFallbackService, + routingEpoch: settingsCubit.state.routingEpoch, + routingEpochResolver: () => settingsCubit.state.routingEpoch, + ); + }, ), - ), - RepositoryProvider( - create: (context) => ProfileActionRepository( - bluesky: bluesky, - appViewProviderResolver: () => context.read().state.appViewProvider, + RepositoryProvider( + create: (context) { + final settingsCubit = context.read(); + return TypeaheadRepository( + bluesky: bluesky, + providerResolver: () => settingsCubit.state.typeaheadProvider, + appViewProviderResolver: () => settingsCubit.state.appViewProvider, + moderationService: context.read(), + ); + }, ), - ), - RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), - RepositoryProvider(create: (_) => PostActionCache()), - RepositoryProvider(create: (_) => VideoRepository(bluesky: bluesky)), - RepositoryProvider.value(value: bluesky), - RepositoryProvider.value(value: widget.database), - RepositoryProvider.value(value: widget.objectBoxStore), - RepositoryProvider.value(value: widget.embeddingService), - RepositoryProvider(create: (context) => EmbeddingRepository(context.read())), - RepositoryProvider( - create: (context) => SemanticIndexer( - embeddingService: context.read(), - embeddingRepository: context.read(), - database: widget.database, + RepositoryProvider( + create: (context) => ListRepository( + bluesky: bluesky, + moderationService: context.read(), + appViewProviderResolver: () => context.read().state.appViewProvider, + ), ), - ), - RepositoryProvider( - create: (context) => LikedPostsRepository( - bluesky: bluesky, - database: widget.database, - semanticIndexer: context.read(), - appViewProviderResolver: () => context.read().state.appViewProvider, + RepositoryProvider( + create: (context) { + final service = context.read(); + return ProfileRepository( + database: widget.database, + bluesky: bluesky, + moderationService: service, + appViewProviderResolver: () => context.read().state.appViewProvider, + ); + }, ), - ), - RepositoryProvider( - create: (context) => SemanticSearchRepository( - embeddingService: context.read(), - embeddingRepository: context.read(), - database: widget.database, + RepositoryProvider( + create: (context) => NotificationRepository( + bluesky: bluesky, + moderationService: context.read(), + appViewProviderResolver: () => context.read().state.appViewProvider, + ), ), - ), - RepositoryProvider.value(value: accountDid), - ], - child: MultiBlocProvider( - providers: [ - BlocProvider(create: (context) => ProfileBloc(profileRepository: context.read())), - BlocProvider(create: (context) => FeedBloc(feedRepository: context.read())), - BlocProvider( - create: (context) => FeedPreferencesCubit( - feedRepository: context.read(), + RepositoryProvider( + create: (context) => NotificationDomainService( + notificationRepository: context.read(), database: widget.database, accountDid: accountDid, - )..loadPreferences(), + localNotificationAdapter: widget.localNotificationAdapter, + shouldSuppressLocalNotifications: _isAlertsRouteActive, + ), ), - BlocProvider(create: (_) => DevToolsCubit(atproto: bluesky.atproto)), - BlocProvider( - create: (context) => SearchBloc( - searchRepository: context.read(), - typeaheadRepository: context.read(), + RepositoryProvider( + create: (context) => PostThreadRepository( + bluesky: bluesky, database: widget.database, accountDid: accountDid, + moderationService: context.read(), + appViewProviderResolver: () => context.read().state.appViewProvider, ), ), - BlocProvider( - create: (context) => - ConvoListBloc(convoRepository: context.read()) - ..add(const ConvosRequested(limit: 100)), + RepositoryProvider( + create: (context) => StarterPackRepository( + bluesky: bluesky, + moderationService: context.read(), + appViewProviderResolver: () => context.read().state.appViewProvider, + ), ), - BlocProvider( - create: (context) => SavedPostsCubit( - database: widget.database, - accountDid: accountDid, - postActionRepository: context.read(), - semanticIndexer: context.read(), + RepositoryProvider( + create: (context) => PostActionRepository( + bluesky: bluesky, + appViewProviderResolver: () => context.read().state.appViewProvider, ), ), - BlocProvider( - create: (context) => SemanticSearchCubit( - repository: context.read(), - embeddingService: context.read(), - accountDid: accountDid, + RepositoryProvider( + create: (context) => ProfileActionRepository( + bluesky: bluesky, + appViewProviderResolver: () => context.read().state.appViewProvider, ), ), - BlocProvider( - create: (context) => SemanticIndexCubit( - indexer: context.read(), + RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), + RepositoryProvider(create: (_) => PostActionCache()), + RepositoryProvider(create: (_) => VideoRepository(bluesky: bluesky)), + RepositoryProvider.value(value: bluesky), + RepositoryProvider.value(value: widget.database), + RepositoryProvider.value(value: widget.objectBoxStore), + RepositoryProvider.value(value: widget.embeddingService), + RepositoryProvider(create: (context) => EmbeddingRepository(context.read())), + RepositoryProvider( + create: (context) => SemanticIndexer( + embeddingService: context.read(), embeddingRepository: context.read(), - accountDid: accountDid, + database: widget.database, ), ), - BlocProvider( - create: (context) => - LikedPostsSyncCubit(repository: context.read(), accountDid: accountDid), + RepositoryProvider( + create: (context) => LikedPostsRepository( + bluesky: bluesky, + database: widget.database, + semanticIndexer: context.read(), + appViewProviderResolver: () => context.read().state.appViewProvider, + ), + ), + RepositoryProvider( + create: (context) => SemanticSearchRepository( + embeddingService: context.read(), + embeddingRepository: context.read(), + database: widget.database, + ), ), + RepositoryProvider.value(value: accountDid), ], - child: appShell, + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => ProfileBloc(profileRepository: context.read()), + ), + BlocProvider(create: (context) => FeedBloc(feedRepository: context.read())), + BlocProvider( + create: (context) => FeedPreferencesCubit( + feedRepository: context.read(), + database: widget.database, + accountDid: accountDid, + )..loadPreferences(), + ), + BlocProvider(create: (_) => DevToolsCubit(atproto: bluesky.atproto)), + BlocProvider( + create: (context) => SearchBloc( + searchRepository: context.read(), + typeaheadRepository: context.read(), + database: widget.database, + accountDid: accountDid, + ), + ), + BlocProvider( + create: (context) => + ConvoListBloc(convoRepository: context.read()) + ..add(const ConvosRequested(limit: 100)), + ), + BlocProvider( + create: (context) => SavedPostsCubit( + database: widget.database, + accountDid: accountDid, + postActionRepository: context.read(), + semanticIndexer: context.read(), + ), + ), + BlocProvider( + create: (context) => SemanticSearchCubit( + repository: context.read(), + embeddingService: context.read(), + accountDid: accountDid, + ), + ), + BlocProvider( + create: (context) => SemanticIndexCubit( + indexer: context.read(), + embeddingRepository: context.read(), + accountDid: accountDid, + ), + ), + BlocProvider( + create: (context) => + LikedPostsSyncCubit(repository: context.read(), accountDid: accountDid), + ), + ], + child: appShell, + ), ), - ), - ); - }, + ); + }, + ), ), ); } diff --git a/pubspec.lock b/pubspec.lock index 57ba604..f052e0f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -505,6 +505,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.6.0" + firebase_crashlytics: + dependency: "direct main" + description: + name: firebase_crashlytics + sha256: "43a311b280d9391389a690d10e1ac0d458b965154a57de5be2f0857225aa2016" + url: "https://pub.dev" + source: hosted + version: "5.2.0" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + sha256: "1b6a921ad6f0d08203ecc1310437a88cec357bc3cad27e1138f1e2c16dd71db9" + url: "https://pub.dev" + source: hosted + version: "3.8.20" firebase_messaging: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index dbc05d0..0971521 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,6 +56,7 @@ dependencies: flutter_local_notifications: ^19.4.2 firebase_core: ^4.0.0 firebase_messaging: ^16.0.0 + firebase_crashlytics: ^5.2.0 dev_dependencies: flutter_test: diff --git a/test/core/crash_reporting/crash_reporting_consent_gate_test.dart b/test/core/crash_reporting/crash_reporting_consent_gate_test.dart new file mode 100644 index 0000000..00a592f --- /dev/null +++ b/test/core/crash_reporting/crash_reporting_consent_gate_test.dart @@ -0,0 +1,156 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/crash_reporting/crash_reporting_consent_gate.dart'; +import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; +import 'package:lazurite/core/theme/app_theme.dart'; +import 'package:lazurite/core/theme/feed_layout.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_state.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAuthBloc extends MockBloc implements AuthBloc {} + +class MockSettingsCubit extends MockCubit implements SettingsCubit {} + +class RecordingCrashReportingService implements CrashReportingService { + final List collectionUpdates = []; + var sendCalls = 0; + var deleteCalls = 0; + + @override + Future deleteUnsentReports() async { + deleteCalls += 1; + } + + @override + Future recordError(Object error, StackTrace stackTrace, {bool fatal = false}) async {} + + @override + void recordFlutterFatalError(FlutterErrorDetails details) {} + + @override + Future sendUnsentReports() async { + sendCalls += 1; + } + + @override + Future setCollectionEnabled(bool enabled) async { + collectionUpdates.add(enabled); + } + + @override + void crash() {} +} + +void main() { + late MockAuthBloc authBloc; + late MockSettingsCubit settingsCubit; + late RecordingCrashReportingService crashReportingService; + late StreamController authController; + late StreamController settingsController; + late SettingsState settingsState; + + setUp(() { + authBloc = MockAuthBloc(); + settingsCubit = MockSettingsCubit(); + crashReportingService = RecordingCrashReportingService(); + authController = StreamController.broadcast(); + settingsController = StreamController.broadcast(); + + settingsState = const SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + feedLayout: FeedLayout.card, + crashReportingEnabled: false, + crashReportingConsentPrompted: false, + ); + + whenListen(authBloc, authController.stream, initialState: const AuthState.unauthenticated()); + whenListen(settingsCubit, settingsController.stream, initialState: settingsState); + + when(() => settingsCubit.setCrashReportingConsentPrompted(any())).thenAnswer((invocation) async { + final prompted = invocation.positionalArguments.first as bool; + settingsState = settingsState.copyWith(crashReportingConsentPrompted: prompted); + settingsController.add(settingsState); + }); + when(() => settingsCubit.setCrashReportingEnabled(any())).thenAnswer((invocation) async { + final enabled = invocation.positionalArguments.first as bool; + settingsState = settingsState.copyWith(crashReportingEnabled: enabled); + settingsController.add(settingsState); + }); + }); + + tearDown(() async { + await authController.close(); + await settingsController.close(); + }); + + Widget buildSubject() { + return MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: settingsCubit), + ], + child: MaterialApp( + home: Scaffold( + body: CrashReportingConsentGate(crashReportingService: crashReportingService, child: const Text('home')), + ), + ), + ); + } + + testWidgets('shows consent dialog once on first authenticated usage', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + expect(find.text('Help Improve Stability?'), findsNothing); + + authController.add( + const AuthState.authenticated(AuthTokens(accessToken: 'token', did: 'did:plc:test', handle: 'test.bsky.social')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Help Improve Stability?'), findsOneWidget); + await tester.tap(find.text('Enable')); + await tester.pumpAndSettle(); + + verify(() => settingsCubit.setCrashReportingConsentPrompted(true)).called(1); + verify(() => settingsCubit.setCrashReportingEnabled(true)).called(1); + expect(crashReportingService.collectionUpdates, [true]); + expect(crashReportingService.sendCalls, 1); + expect(crashReportingService.deleteCalls, 0); + + authController.add( + const AuthState.authenticated( + AuthTokens(accessToken: 'token2', did: 'did:plc:other', handle: 'other.bsky.social'), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Help Improve Stability?'), findsNothing); + }); + + testWidgets('not now persists decision and disables collection', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + authController.add( + const AuthState.authenticated(AuthTokens(accessToken: 'token', did: 'did:plc:test', handle: 'test.bsky.social')), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Not now')); + await tester.pumpAndSettle(); + + verify(() => settingsCubit.setCrashReportingConsentPrompted(true)).called(1); + verify(() => settingsCubit.setCrashReportingEnabled(false)).called(1); + expect(crashReportingService.collectionUpdates, [false]); + expect(crashReportingService.sendCalls, 0); + expect(crashReportingService.deleteCalls, 1); + }); +} diff --git a/test/core/crash_reporting/crash_reporting_service_test.dart b/test/core/crash_reporting/crash_reporting_service_test.dart new file mode 100644 index 0000000..50029d1 --- /dev/null +++ b/test/core/crash_reporting/crash_reporting_service_test.dart @@ -0,0 +1,57 @@ +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockFirebaseCrashlytics extends Mock implements FirebaseCrashlytics {} + +void main() { + setUpAll(() { + registerFallbackValue(FlutterErrorDetails(exception: Exception('fallback'), stack: StackTrace.empty)); + }); + + late MockFirebaseCrashlytics crashlytics; + late FirebaseCrashReportingService service; + + setUp(() { + crashlytics = MockFirebaseCrashlytics(); + service = FirebaseCrashReportingService(crashlytics: crashlytics); + + when(() => crashlytics.setCrashlyticsCollectionEnabled(any())).thenAnswer((_) async {}); + when(() => crashlytics.recordError(any(), any(), fatal: any(named: 'fatal'))).thenAnswer((_) async {}); + when(() => crashlytics.recordFlutterFatalError(any())).thenAnswer((_) async {}); + when(() => crashlytics.sendUnsentReports()).thenAnswer((_) async {}); + when(() => crashlytics.deleteUnsentReports()).thenAnswer((_) async {}); + }); + + test('setCollectionEnabled delegates to Firebase Crashlytics', () async { + await service.setCollectionEnabled(true); + verify(() => crashlytics.setCrashlyticsCollectionEnabled(true)).called(1); + }); + + test('setCollectionEnabled does not throw when plugin call fails', () async { + when(() => crashlytics.setCrashlyticsCollectionEnabled(any())).thenThrow(Exception('boom')); + await service.setCollectionEnabled(false); + verify(() => crashlytics.setCrashlyticsCollectionEnabled(false)).called(1); + }); + + test('recordError does not throw when plugin call fails', () async { + when(() => crashlytics.recordError(any(), any(), fatal: any(named: 'fatal'))).thenThrow(Exception('boom')); + await service.recordError(Exception('error'), StackTrace.current, fatal: true); + verify(() => crashlytics.recordError(any(), any(), fatal: true)).called(1); + }); + + test('recordFlutterFatalError does not throw when plugin call fails', () async { + when(() => crashlytics.recordFlutterFatalError(any())).thenThrow(Exception('boom')); + service.recordFlutterFatalError(FlutterErrorDetails(exception: Exception('fatal'), stack: StackTrace.current)); + await Future.delayed(const Duration(milliseconds: 1)); + verify(() => crashlytics.recordFlutterFatalError(any())).called(1); + }); + + test('crash delegates to Firebase Crashlytics', () { + when(() => crashlytics.crash()).thenReturn(null); + service.crash(); + verify(() => crashlytics.crash()).called(1); + }); +} diff --git a/test/features/settings/bloc/settings_cubit_test.dart b/test/features/settings/bloc/settings_cubit_test.dart index 1178750..edaccb2 100644 --- a/test/features/settings/bloc/settings_cubit_test.dart +++ b/test/features/settings/bloc/settings_cubit_test.dart @@ -33,6 +33,8 @@ void main() { expect(cubit.state.appViewProvider, 'bluesky'); expect(cubit.state.crossProviderFallbackEnabled, isFalse); expect(cubit.state.slingshotIdentityFallbackEnabled, isFalse); + expect(cubit.state.crashReportingEnabled, isFalse); + expect(cubit.state.crashReportingConsentPrompted, isFalse); expect(cubit.state.routingEpoch, 0); }); @@ -97,7 +99,9 @@ void main() { .having((s) => s.typeaheadProvider, 'typeaheadProvider', 'bluesky') .having((s) => s.appViewProvider, 'appViewProvider', 'bluesky') .having((s) => s.crossProviderFallbackEnabled, 'crossProviderFallbackEnabled', false) - .having((s) => s.slingshotIdentityFallbackEnabled, 'slingshotIdentityFallbackEnabled', false), + .having((s) => s.slingshotIdentityFallbackEnabled, 'slingshotIdentityFallbackEnabled', false) + .having((s) => s.crashReportingEnabled, 'crashReportingEnabled', false) + .having((s) => s.crashReportingConsentPrompted, 'crashReportingConsentPrompted', false), ], ); @@ -385,6 +389,43 @@ void main() { ], ); + blocTest( + 'setCrashReportingEnabled updates state and persists to database', + build: () => SettingsCubit(database: database), + act: (cubit) => cubit.setCrashReportingEnabled(true), + expect: () => [isA().having((s) => s.crashReportingEnabled, 'crashReportingEnabled', true)], + verify: (_) async { + expect(await database.getSetting('crash_reporting_enabled'), 'true'); + }, + ); + + blocTest( + 'setCrashReportingConsentPrompted updates state and persists to database', + build: () => SettingsCubit(database: database), + act: (cubit) => cubit.setCrashReportingConsentPrompted(true), + expect: () => [ + isA().having((s) => s.crashReportingConsentPrompted, 'crashReportingConsentPrompted', true), + ], + verify: (_) async { + expect(await database.getSetting('crash_reporting_consent_prompted'), 'true'); + }, + ); + + blocTest( + 'loadSettings restores crash reporting settings', + build: () => SettingsCubit(database: database), + setUp: () async { + await database.setSetting('crash_reporting_enabled', 'true'); + await database.setSetting('crash_reporting_consent_prompted', 'true'); + }, + act: (cubit) => cubit.loadSettings(), + expect: () => [ + isA() + .having((s) => s.crashReportingEnabled, 'crashReportingEnabled', true) + .having((s) => s.crashReportingConsentPrompted, 'crashReportingConsentPrompted', true), + ], + ); + blocTest( 'bumpRoutingEpoch increments the in-memory routing epoch', build: () => SettingsCubit(database: database), diff --git a/test/features/settings/bloc/settings_state_test.dart b/test/features/settings/bloc/settings_state_test.dart index 4b5aea8..a19bc66 100644 --- a/test/features/settings/bloc/settings_state_test.dart +++ b/test/features/settings/bloc/settings_state_test.dart @@ -151,6 +151,8 @@ void main() { appViewProvider: 'blacksky', crossProviderFallbackEnabled: true, slingshotIdentityFallbackEnabled: true, + crashReportingEnabled: true, + crashReportingConsentPrompted: true, ); expect(updated.themePalette, AppThemePalette.nord); @@ -163,6 +165,8 @@ void main() { expect(updated.appViewProvider, 'blacksky'); expect(updated.crossProviderFallbackEnabled, isTrue); expect(updated.slingshotIdentityFallbackEnabled, isTrue); + expect(updated.crashReportingEnabled, isTrue); + expect(updated.crashReportingConsentPrompted, isTrue); expect(original.themePalette, AppThemePalette.oxocarbon); }); @@ -189,6 +193,8 @@ void main() { expect(updated.appViewProvider, 'bluesky'); expect(updated.crossProviderFallbackEnabled, isFalse); expect(updated.slingshotIdentityFallbackEnabled, isFalse); + expect(updated.crashReportingEnabled, isFalse); + expect(updated.crashReportingConsentPrompted, isFalse); }); test('copyWith can clear threadAutoCollapseDepth', () { @@ -224,6 +230,7 @@ void main() { expect(state.props, contains(6)); expect(state.props, contains('bluesky')); expect(state.props, contains(false)); + expect(state.props, contains(false)); }); test('defaults feedLayout to card', () { @@ -279,6 +286,8 @@ void main() { ); expect(state.crossProviderFallbackEnabled, isFalse); expect(state.slingshotIdentityFallbackEnabled, isFalse); + expect(state.crashReportingEnabled, isFalse); + expect(state.crashReportingConsentPrompted, isFalse); }); }); } diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 69a29ab..b55e4d2 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/theme/app_theme.dart'; @@ -23,15 +24,41 @@ class MockAuthBloc extends MockBloc implements AuthBloc {} class MockSettingsCubit extends MockCubit implements SettingsCubit {} +class FakeCrashReportingService implements CrashReportingService { + var crashCalls = 0; + + @override + void crash() { + crashCalls += 1; + } + + @override + Future deleteUnsentReports() async {} + + @override + Future recordError(Object error, StackTrace stackTrace, {bool fatal = false}) async {} + + @override + void recordFlutterFatalError(FlutterErrorDetails details) {} + + @override + Future sendUnsentReports() async {} + + @override + Future setCollectionEnabled(bool enabled) async {} +} + void main() { late MockAccountSwitcherCubit accountSwitcherCubit; late MockAuthBloc authBloc; late MockSettingsCubit settingsCubit; + late FakeCrashReportingService crashReportingService; setUp(() { accountSwitcherCubit = MockAccountSwitcherCubit(); authBloc = MockAuthBloc(); settingsCubit = MockSettingsCubit(); + crashReportingService = FakeCrashReportingService(); when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); whenListen(authBloc, const Stream.empty(), initialState: const AuthState.unauthenticated()); @@ -62,16 +89,21 @@ void main() { ); when(() => settingsCubit.setAppViewProvider(any())).thenAnswer((_) async {}); when(() => settingsCubit.refreshAppViewHealth()).thenAnswer((_) async {}); + when(() => settingsCubit.setCrashReportingEnabled(any())).thenAnswer((_) async {}); + when(() => settingsCubit.setCrashReportingConsentPrompted(any())).thenAnswer((_) async {}); }); Widget buildSubject() { - return MultiBlocProvider( - providers: [ - BlocProvider.value(value: authBloc), - BlocProvider.value(value: accountSwitcherCubit), - BlocProvider.value(value: settingsCubit), - ], - child: const MaterialApp(home: SettingsScreen()), + return RepositoryProvider.value( + value: crashReportingService, + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: accountSwitcherCubit), + BlocProvider.value(value: settingsCubit), + ], + child: const MaterialApp(home: SettingsScreen()), + ), ); } @@ -80,13 +112,16 @@ void main() { routes: [ GoRoute( path: '/', - builder: (context, state) => MultiBlocProvider( - providers: [ - BlocProvider.value(value: authBloc), - BlocProvider.value(value: accountSwitcherCubit), - BlocProvider.value(value: settingsCubit), - ], - child: const SettingsScreen(), + builder: (context, state) => RepositoryProvider.value( + value: crashReportingService, + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: accountSwitcherCubit), + BlocProvider.value(value: settingsCubit), + ], + child: const SettingsScreen(), + ), ), ), GoRoute( @@ -261,11 +296,43 @@ void main() { expect(find.text('AppView Provider'), findsOneWidget); expect(find.text('Cross-Provider Fallback'), findsOneWidget); expect(find.text('Slingshot Identity Fallback'), findsOneWidget); + expect(find.text('Crash Reporting'), findsOneWidget); expect(find.text('Provider Diagnostics'), findsOneWidget); expect(find.text('Refresh Provider Health'), findsOneWidget); expect(find.byIcon(Icons.edit_outlined), findsNothing); }); + testWidgets('crash reporting toggle persists consent and reporting state', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible(find.text('Crash Reporting'), 300); + await tester.pumpAndSettle(); + + final crashTile = find.ancestor(of: find.text('Crash Reporting'), matching: find.byType(ListTile)); + await tester.ensureVisible(crashTile); + final crashSwitch = find.descendant(of: crashTile, matching: find.byType(Switch)); + expect(crashSwitch, findsOneWidget); + await tester.tap(crashSwitch); + await tester.pumpAndSettle(); + + verify(() => settingsCubit.setCrashReportingEnabled(true)).called(1); + verify(() => settingsCubit.setCrashReportingConsentPrompted(true)).called(1); + }); + + testWidgets('developer crash row triggers crash reporting test crash', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible(find.text('Crashlytics Test Crash'), 300); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Crashlytics Test Crash')); + await tester.pumpAndSettle(); + + expect(crashReportingService.crashCalls, 1); + }); + testWidgets('provider change confirmation can be cancelled', (tester) async { when(() => settingsCubit.state).thenReturn( const SettingsState(