feat(mobile): default crash reporting on

Co-authored-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr
2026-07-19 16:50:28 -07:00
parent 63a9348629
commit a072957adb
4 changed files with 125 additions and 57 deletions
@@ -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:
@@ -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;
@@ -12,11 +12,11 @@ void main() {
late _RecordingCrashReporter reporter;
Future<DiagnosticsController> 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<Switch>(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<Switch>(find.byType(Switch)).value, isFalse);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
@@ -91,13 +98,14 @@ void main() {
expect(tester.widget<Switch>(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<Switch>(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(
@@ -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<StateError>()));
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<void>();
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<StateError>()));
@@ -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<StateError>()));
@@ -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);