diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 8975633db..b70bf3142 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -108,19 +108,16 @@ class SettingsPage extends HookConsumerWidget { ], ), - // Diagnostics + // Crash reporting AppListSection( - label: 'Diagnostics', children: [ AppListRow( icon: LucideIcons.activity, - title: 'Share Crash Reports', + title: 'Share crash reports', subtitle: diagnostics.isConfigured - ? 'Send crash details to help improve Buzz. Reports ' - 'do not include screenshots, view hierarchy, ' - 'breadcrumbs, or performance traces.' + ? 'Sent anonymously to help fix problems.' : 'Crash reporting is unavailable in this build.', - subtitleMaxLines: 4, + subtitleMaxLines: 2, trailing: Switch.adaptive( value: diagnostics.consentGranted, onChanged: diff --git a/mobile/lib/shared/diagnostics/diagnostics_controller.dart b/mobile/lib/shared/diagnostics/diagnostics_controller.dart index 522a3d3da..c7cddefde 100644 --- a/mobile/lib/shared/diagnostics/diagnostics_controller.dart +++ b/mobile/lib/shared/diagnostics/diagnostics_controller.dart @@ -7,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'sentry_config.dart'; const diagnosticsConsentPreferenceKey = 'buzz_crash_reporting_consent'; +const _diagnosticsEnabledByDefault = true; typedef DiagnosticsLog = void Function(String message); @@ -38,7 +39,8 @@ class DiagnosticsController extends ChangeNotifier { _crashReporter = crashReporter, _log = log ?? debugPrint, _consentGranted = - preferences.getBool(diagnosticsConsentPreferenceKey) ?? false; + preferences.getBool(diagnosticsConsentPreferenceKey) ?? + _diagnosticsEnabledByDefault; final SharedPreferences _preferences; final SentryConfig _config; diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index 3d04a9d95..89896ecc2 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -12,11 +12,11 @@ void main() { late _RecordingCrashReporter reporter; Future createController({ - bool consent = false, + bool? consent, String dsn = 'https://public@example.invalid/1', }) async { SharedPreferences.setMockInitialValues({ - diagnosticsConsentPreferenceKey: consent, + diagnosticsConsentPreferenceKey: ?consent, }); preferences = await SharedPreferences.getInstance(); reporter = _RecordingCrashReporter(); @@ -50,23 +50,30 @@ void main() { await tester.pump(); } - testWidgets('diagnostics switch enables crash reporting', (tester) async { + testWidgets('diagnostics switch defaults on with approved copy', ( + tester, + ) async { final controller = await createController(); + await controller.applyStartupConsent(); await pumpSettings(tester, controller); - expect(find.text('DIAGNOSTICS'), findsOneWidget); - expect(find.text('Share Crash Reports'), findsOneWidget); - expect( - find.text( - 'Send crash details to help improve Buzz. Reports do not include ' - 'screenshots, view hierarchy, breadcrumbs, or performance traces.', - ), - findsOneWidget, - ); - expect(find.textContaining('personal information'), findsNothing); - expect(find.textContaining('message text'), findsNothing); + expect(find.text('DIAGNOSTICS'), findsNothing); + expect(find.text('Share crash reports'), findsOneWidget); + expect(find.text('Sent anonymously to help fix problems.'), findsOneWidget); + expect(find.text('Share Crash Reports'), findsNothing); final toggle = tester.widget(find.byType(Switch)); - expect(toggle.value, isFalse); + expect(toggle.value, isTrue); + expect(preferences.getBool(diagnosticsConsentPreferenceKey), isNull); + expect(reporter.initializeCalls, 1); + }); + + testWidgets('explicit opt-in persists and initializes immediately', ( + tester, + ) async { + final controller = await createController(consent: false); + await pumpSettings(tester, controller); + + expect(tester.widget(find.byType(Switch)).value, isFalse); await tester.tap(find.byType(Switch)); await tester.pumpAndSettle(); @@ -91,13 +98,14 @@ void main() { expect(tester.widget(find.byType(Switch)).value, isFalse); expect(controller.consentGranted, isFalse); + expect(preferences.getBool(diagnosticsConsentPreferenceKey), isFalse); expect(reporter.closeCalls, 1); }); testWidgets('reports and rolls back an initialization failure', ( tester, ) async { - final controller = await createController(); + final controller = await createController(consent: false); reporter.initializeError = StateError('init failed'); await pumpSettings(tester, controller); @@ -132,10 +140,34 @@ void main() { expect(reporter.initializeCalls, 0); }); - testWidgets('diagnostics switch is disabled without a release DSN', ( + testWidgets('default-on preference is visible without a release DSN', ( tester, ) async { final controller = await createController(dsn: ''); + await controller.applyStartupConsent(); + await pumpSettings(tester, controller); + + expect( + find.text('Crash reporting is unavailable in this build.'), + findsOneWidget, + ); + final toggle = tester.widget(find.byType(Switch)); + expect(toggle.value, isTrue); + expect(toggle.onChanged, isNotNull); + expect(preferences.getBool(diagnosticsConsentPreferenceKey), isNull); + expect(reporter.initializeCalls, 0); + + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + + expect(controller.consentGranted, isFalse); + expect(preferences.getBool(diagnosticsConsentPreferenceKey), isFalse); + }); + + testWidgets('explicit opt-out is disabled without a release DSN', ( + tester, + ) async { + final controller = await createController(consent: false, dsn: ''); await pumpSettings(tester, controller); expect( diff --git a/mobile/test/shared/diagnostics/diagnostics_controller_test.dart b/mobile/test/shared/diagnostics/diagnostics_controller_test.dart index 09712430c..ce8db62a0 100644 --- a/mobile/test/shared/diagnostics/diagnostics_controller_test.dart +++ b/mobile/test/shared/diagnostics/diagnostics_controller_test.dart @@ -30,30 +30,54 @@ void main() { ); } - test( - 'defaults consent to false and does not initialize at startup', - () async { - final controller = await createController(); + test('missing preference defaults reporting on for first installs', () async { + final controller = await createController(); - await controller.applyStartupConsent(); + await controller.applyStartupConsent(); - expect(controller.consentGranted, isFalse); - expect(reporter.initializeCalls, 0); - expect(reporter.closeCalls, 0); - expect( - logs, - contains( - 'Diagnostics disabled: user consent is off; Sentry not initialized.', - ), - ); - }, - ); - - test('empty DSN prevents initialization even with stored consent', () async { - final controller = await createController( - storedValues: {diagnosticsConsentPreferenceKey: true}, - dsn: ' ', + expect(controller.consentGranted, isTrue); + expect(preferences.getBool(diagnosticsConsentPreferenceKey), isNull); + expect(reporter.initializeCalls, 1); + expect(reporter.closeCalls, 0); + expect( + logs, + contains('Diagnostics enabled: Sentry initialized after user consent.'), ); + }); + + test('missing preference defaults reporting on after an upgrade', () async { + final controller = await createController( + storedValues: {'existing_preference_from_older_version': true}, + ); + + await controller.applyStartupConsent(); + + expect(controller.consentGranted, isTrue); + expect(preferences.getBool(diagnosticsConsentPreferenceKey), isNull); + expect(reporter.initializeCalls, 1); + }); + + test('explicitly stored revocation remains off at startup', () async { + final controller = await createController( + storedValues: {diagnosticsConsentPreferenceKey: false}, + ); + + await controller.applyStartupConsent(); + + expect(controller.consentGranted, isFalse); + expect(preferences.getBool(diagnosticsConsentPreferenceKey), isFalse); + expect(reporter.initializeCalls, 0); + expect(reporter.closeCalls, 0); + expect( + logs, + contains( + 'Diagnostics disabled: user consent is off; Sentry not initialized.', + ), + ); + }); + + test('empty DSN prevents initialization with default-on reporting', () async { + final controller = await createController(dsn: ' '); await controller.applyStartupConsent(); @@ -68,13 +92,16 @@ void main() { ); }); - test('cannot enable consent in an unconfigured build', () async { - final controller = await createController(dsn: ' '); + test('cannot opt in to an unconfigured build after revocation', () async { + final controller = await createController( + storedValues: {diagnosticsConsentPreferenceKey: false}, + dsn: ' ', + ); await expectLater(controller.setConsent(true), throwsA(isA())); expect(controller.consentGranted, isFalse); - expect(preferences.getBool(diagnosticsConsentPreferenceKey), isNull); + expect(preferences.getBool(diagnosticsConsentPreferenceKey), isFalse); expect(reporter.initializeCalls, 0); expect( logs, @@ -101,8 +128,10 @@ void main() { ); }); - test('enabling persists consent and initializes immediately', () async { - final controller = await createController(); + test('explicit opt-in persists and initializes immediately', () async { + final controller = await createController( + storedValues: {diagnosticsConsentPreferenceKey: false}, + ); await controller.setConsent(true); @@ -146,7 +175,9 @@ void main() { }); test('serializes concurrent consent changes', () async { - final controller = await createController(); + final controller = await createController( + storedValues: {diagnosticsConsentPreferenceKey: false}, + ); reporter.initializeGate = Completer(); final enable = controller.setConsent(true); @@ -163,8 +194,10 @@ void main() { expect(reporter.closeCalls, 1); }); - test('initialization failure rolls back consent', () async { - final controller = await createController(); + test('initialization failure preserves explicit revocation', () async { + final controller = await createController( + storedValues: {diagnosticsConsentPreferenceKey: false}, + ); reporter.initializeError = StateError('init failed'); await expectLater(controller.setConsent(true), throwsA(isA())); @@ -189,7 +222,9 @@ void main() { }); test('continues accepting changes after a failed operation', () async { - final controller = await createController(); + final controller = await createController( + storedValues: {diagnosticsConsentPreferenceKey: false}, + ); reporter.initializeError = StateError('init failed'); await expectLater(controller.setConsent(true), throwsA(isA())); @@ -201,8 +236,10 @@ void main() { expect(reporter.initializeCalls, 2); }); - test('repeating the current value is idempotent', () async { - final controller = await createController(); + test('repeating explicit values is idempotent', () async { + final controller = await createController( + storedValues: {diagnosticsConsentPreferenceKey: false}, + ); await controller.setConsent(false); await controller.setConsent(true);