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(