feat(mobile): add consent-gated crash reporting

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 13:09:19 -07:00
parent b1ef791cb8
commit fb375ddf24
13 changed files with 912 additions and 15 deletions
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<application
@@ -44,6 +45,17 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- Sentry must not execute before Dart loads persisted diagnostics
consent and explicitly initializes the SDK. -->
<provider
android:name="io.sentry.android.core.SentryInitProvider"
tools:node="remove" />
<provider
android:name="io.sentry.android.core.SentryPerformanceProvider"
tools:node="remove" />
<provider
android:name="io.sentry.ndk.SentryNdkPreloadProvider"
tools:node="remove" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
+20
View File
@@ -1,6 +1,8 @@
PODS:
- app_badge_plus (1.2.10):
- Flutter
- app_links (6.4.1):
- Flutter
- connectivity_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
@@ -14,6 +16,11 @@ PODS:
- FlutterMacOS
- package_info_plus (0.4.5):
- Flutter
- Sentry/HybridSDK (8.58.3)
- sentry_flutter (9.24.0):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.58.3)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
@@ -25,19 +32,27 @@ PODS:
DEPENDENCIES:
- app_badge_plus (from `.symlinks/plugins/app_badge_plus/ios`)
- app_links (from `.symlinks/plugins/app_links/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
SPEC REPOS:
trunk:
- Sentry
EXTERNAL SOURCES:
app_badge_plus:
:path: ".symlinks/plugins/app_badge_plus/ios"
app_links:
:path: ".symlinks/plugins/app_links/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
Flutter:
@@ -50,6 +65,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/mobile_scanner/darwin"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
sentry_flutter:
:path: ".symlinks/plugins/sentry_flutter/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
url_launcher_ios:
@@ -59,12 +76,15 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
app_badge_plus: 09939f19a075cc742cc155d8ed85e6d8601f0104
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
Sentry: 108fdbb76299c4189af12246bf0308c09c278922
sentry_flutter: 8528594bf73819ef3f40c2e218e93554fc2719e6
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a
@@ -7,6 +7,7 @@ import 'package:package_info_plus/package_info_plus.dart';
import '../../shared/auth/auth.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/diagnostics/diagnostics.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/app_list.dart';
@@ -27,6 +28,7 @@ class SettingsPage extends HookConsumerWidget {
final selectedAccent = ref.watch(accentProvider);
final selectedScheme = ref.watch(schemeProvider);
final colorScheme = context.colors;
final diagnostics = ref.watch(diagnosticsControllerProvider);
final packageInfoFuture = useMemoized(() => PackageInfo.fromPlatform());
final packageInfo = useFuture(packageInfoFuture);
@@ -106,6 +108,45 @@ class SettingsPage extends HookConsumerWidget {
],
),
// Diagnostics
AppListSection(
label: 'Diagnostics',
children: [
AppListRow(
icon: LucideIcons.activity,
title: 'Share Crash Reports',
subtitle: diagnostics.isConfigured
? 'Send crash details to help improve Buzz. Reports '
'exclude message text, screenshots, and personal '
'information.'
: 'Crash reporting is unavailable in this build.',
subtitleMaxLines: 4,
trailing: Switch.adaptive(
value: diagnostics.consentGranted,
onChanged:
diagnostics.isConfigured ||
diagnostics.consentGranted
? (value) async {
final messenger = ScaffoldMessenger.of(context);
try {
await diagnostics.setConsent(value);
} on Object catch (error) {
messenger.showSnackBar(
SnackBar(
content: Text(
'Could not update crash reporting: '
'$error',
),
),
);
}
}
: null,
),
),
],
),
// Connection
AppListSection(
label: 'Connection',
+15 -2
View File
@@ -3,17 +3,30 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
import 'shared/diagnostics/diagnostics.dart';
import 'shared/theme/theme_provider.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Pre-load preferences so the first frame uses the saved theme/accent.
// Pre-load preferences so startup can apply saved diagnostics consent and
// the first frame uses the saved theme/accent.
final prefs = await SharedPreferences.getInstance();
final diagnosticsController = DiagnosticsController(
preferences: prefs,
config: const SentryConfig.fromEnvironment(),
crashReporter: const SentryCrashReporter(),
);
await diagnosticsController.applyStartupConsent();
runApp(
ProviderScope(
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
diagnosticsControllerProvider.overrideWith(
(_) => diagnosticsController,
),
],
child: const App(),
),
);
@@ -0,0 +1,3 @@
export 'diagnostics_controller.dart';
export 'diagnostics_provider.dart';
export 'sentry_config.dart';
@@ -0,0 +1,135 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'sentry_config.dart';
const diagnosticsConsentPreferenceKey = 'buzz_crash_reporting_consent';
typedef DiagnosticsLog = void Function(String message);
abstract interface class CrashReporter {
Future<void> initialize(SentryConfig config);
Future<void> close();
}
class SentryCrashReporter implements CrashReporter {
const SentryCrashReporter();
@override
Future<void> initialize(SentryConfig config) {
return SentryFlutter.init(config.applyTo);
}
@override
Future<void> close() => Sentry.close();
}
class DiagnosticsController extends ChangeNotifier {
DiagnosticsController({
required SharedPreferences preferences,
required SentryConfig config,
required CrashReporter crashReporter,
DiagnosticsLog? log,
}) : _preferences = preferences,
_config = config,
_crashReporter = crashReporter,
_log = log ?? debugPrint,
_consentGranted =
preferences.getBool(diagnosticsConsentPreferenceKey) ?? false;
final SharedPreferences _preferences;
final SentryConfig _config;
final CrashReporter _crashReporter;
final DiagnosticsLog _log;
bool _consentGranted;
bool _initialized = false;
Future<void> _pendingOperation = Future.value();
bool get consentGranted => _consentGranted;
bool get isConfigured => _config.isConfigured;
/// Applies persisted consent before the app starts rendering.
Future<void> applyStartupConsent() => _serialize(_applyCurrentConsent);
/// Persists consent and applies it immediately.
Future<void> setConsent(bool granted) {
return _serialize(() async {
if (_consentGranted == granted) {
return;
}
if (granted && !_config.isConfigured) {
_log(
'Diagnostics unchanged: SENTRY_DSN is empty; consent not enabled.',
);
throw StateError('Crash reporting is unavailable in this build');
}
final previousConsent = _consentGranted;
_consentGranted = granted;
notifyListeners();
try {
final persisted = await _preferences.setBool(
diagnosticsConsentPreferenceKey,
granted,
);
if (!persisted) {
throw StateError('Failed to persist diagnostics consent');
}
await _applyCurrentConsent();
} on Object {
_consentGranted = previousConsent;
final rolledBack = await _preferences.setBool(
diagnosticsConsentPreferenceKey,
previousConsent,
);
notifyListeners();
if (!rolledBack) {
throw StateError('Failed to roll back diagnostics consent');
}
rethrow;
}
});
}
Future<void> _applyCurrentConsent() async {
if (!_consentGranted) {
if (_initialized) {
await _crashReporter.close();
_initialized = false;
_log('Diagnostics disabled: user consent is off; Sentry closed.');
} else {
_log(
'Diagnostics disabled: user consent is off; Sentry not initialized.',
);
}
return;
}
if (!_config.isConfigured) {
_log(
'Diagnostics disabled: SENTRY_DSN is empty; Sentry not initialized.',
);
return;
}
if (_initialized) {
_log('Diagnostics already enabled: Sentry initialization skipped.');
return;
}
await _crashReporter.initialize(_config);
_initialized = true;
_log('Diagnostics enabled: Sentry initialized after user consent.');
}
Future<void> _serialize(Future<void> Function() operation) {
final result = _pendingOperation.then((_) => operation());
_pendingOperation = result.catchError((Object _) {});
return result;
}
}
@@ -0,0 +1,9 @@
import 'package:hooks_riverpod/legacy.dart';
import 'diagnostics_controller.dart';
/// Created during startup and overridden in main.dart.
final diagnosticsControllerProvider =
ChangeNotifierProvider<DiagnosticsController>(
(_) => throw UnimplementedError('Must be overridden'),
);
@@ -0,0 +1,85 @@
// Sentry marks these stable configuration fields as experimental.
// ignore_for_file: experimental_member_use
import 'package:sentry_flutter/sentry_flutter.dart';
/// Compile-time Sentry settings injected only by official release builds.
class SentryConfig {
const SentryConfig({
required this.dsn,
required this.release,
required this.dist,
required this.environment,
});
const SentryConfig.fromEnvironment()
: dsn = const String.fromEnvironment('SENTRY_DSN'),
release = const String.fromEnvironment('SENTRY_RELEASE'),
dist = const String.fromEnvironment('SENTRY_DIST'),
environment = const String.fromEnvironment('SENTRY_ENVIRONMENT');
final String dsn;
final String release;
final String dist;
final String environment;
bool get isConfigured => dsn.trim().isNotEmpty;
void applyTo(SentryFlutterOptions options) {
final trimmedDsn = dsn.trim();
final trimmedRelease = release.trim();
final trimmedDist = dist.trim();
final trimmedEnvironment = environment.trim();
options
..dsn = trimmedDsn
..sendDefaultPii = false
..attachScreenshot = false
..attachViewHierarchy = false
..reportViewHierarchyIdentifiers = false
..enableAutoSessionTracking = false
..enableWatchdogTerminationTracking = false
..enableAppHangTracking = false
..anrEnabled = false
..enableTombstone = false
..enableNdkScopeSync = false
..enableLogs = false
..enableMetrics = false
..reportPackages = false
..sendClientReports = false
..maxBreadcrumbs = 0
..tracesSampleRate = 0
..profilesSampleRate = 0
..enableAutoPerformanceTracing = false
..enableUserInteractionTracing = false
..enableUserInteractionBreadcrumbs = false
..enableTimeToFullDisplayTracing = false
..enableFramesTracking = false
..enableNativeTraceSync = false
..enablePrintBreadcrumbs = false
..enableAutoNativeBreadcrumbs = false
..enableAppLifecycleBreadcrumbs = false
..enableWindowMetricBreadcrumbs = false
..enableBrightnessChangeBreadcrumbs = false
..enableTextScaleChangeBreadcrumbs = false
..enableMemoryPressureBreadcrumbs = false
..recordHttpBreadcrumbs = false
..captureFailedRequests = false
..captureNativeFailedRequests = false
..maxRequestBodySize = MaxRequestBodySize.never
..replay.sessionSampleRate = 0
..replay.onErrorSampleRate = 0
..privacy.maskAllText = true
..privacy.maskAllImages = true;
if (trimmedRelease.isNotEmpty) {
options.release = trimmedRelease;
}
if (trimmedDist.isNotEmpty) {
options.dist = trimmedDist;
}
if (trimmedEnvironment.isNotEmpty) {
options.environment = trimmedEnvironment;
}
}
}
+76 -12
View File
@@ -41,6 +41,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.13.10"
ansicolor:
dependency: transitive
description:
name: ansicolor
sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
app_badge_plus:
dependency: "direct main"
description:
@@ -504,6 +512,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
globbing:
dependency: transitive
description:
name: globbing
sha256: "4f89cfaf6fa74c9c1740a96259da06bd45411ede56744e28017cc534a12b6e2d"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
gpt_markdown:
dependency: "direct main"
description:
@@ -640,6 +656,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.2"
injector:
dependency: transitive
description:
name: injector
sha256: ed389bed5b48a699d5b9561c985023d0d5cc88dd5ff2237aadcce5a5ab433e4e
url: "https://pub.dev"
source: hosted
version: "3.0.0"
intl:
dependency: "direct main"
description:
@@ -660,18 +684,10 @@ packages:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
version: "0.14.2"
json_annotation:
dependency: transitive
description:
@@ -876,10 +892,10 @@ packages:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
version: "2.2.23"
path_provider_foundation:
dependency: transitive
description:
@@ -952,6 +968,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.2"
process:
dependency: transitive
description:
name: process
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
url: "https://pub.dev"
source: hosted
version: "5.0.5"
properties:
dependency: transitive
description:
name: properties
sha256: "333f427dd4ed07bdbe8c75b9ff864a1e70b5d7a8426a2e8bdd457b65ae5ac598"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
provider:
dependency: transitive
description:
@@ -1016,6 +1048,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.8"
sentry:
dependency: transitive
description:
name: sentry
sha256: "09c573f98ff6c5e98d527f351f037eb8ea9ff684a6e9e84b2e91357144016254"
url: "https://pub.dev"
source: hosted
version: "9.24.0"
sentry_dart_plugin:
dependency: "direct dev"
description:
name: sentry_dart_plugin
sha256: da9c1d0b3c87a251bfc36301f16af090a88c2d59128fe9a6f908f5ac20340c97
url: "https://pub.dev"
source: hosted
version: "3.4.0"
sentry_flutter:
dependency: "direct main"
description:
name: sentry_flutter
sha256: "4a04b7e1901b8128df783b6d36d48706b07b0cd055e621517c998de87f5dafb9"
url: "https://pub.dev"
source: hosted
version: "9.24.0"
shared_preferences:
dependency: "direct main"
description:
@@ -1173,6 +1229,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
system_info2:
dependency: transitive
description:
name: system_info2
sha256: b937736ecfa63c45b10dde1ceb6bb30e5c0c340e14c441df024150679d65ac43
url: "https://pub.dev"
source: hosted
version: "4.1.0"
term_glyph:
dependency: transitive
description:
+9
View File
@@ -32,6 +32,7 @@ dependencies:
app_badge_plus: ^1.2.10
app_links: ^6.4.0
scrollable_positioned_list: ^0.3.8
sentry_flutter: 9.24.0
dev_dependencies:
flutter_test:
@@ -40,6 +41,14 @@ dev_dependencies:
custom_lint: ^0.8.0
riverpod_lint: ^3.1.0
mocktail: ^1.0.4
sentry_dart_plugin: 3.4.0
# Symbol uploads are invoked explicitly by official release CI. Keep credentials
# and project identifiers out of this file; the pipeline passes them at runtime.
sentry:
upload_debug_symbols: true
upload_sources: false
commits: false
flutter:
uses-material-design: true
@@ -0,0 +1,170 @@
import 'package:buzz/features/settings/settings_page.dart';
import 'package:buzz/shared/diagnostics/diagnostics.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
late SharedPreferences preferences;
late _RecordingCrashReporter reporter;
Future<DiagnosticsController> createController({
bool consent = false,
String dsn = 'https://public@example.invalid/1',
}) async {
SharedPreferences.setMockInitialValues({
diagnosticsConsentPreferenceKey: consent,
});
preferences = await SharedPreferences.getInstance();
reporter = _RecordingCrashReporter();
return DiagnosticsController(
preferences: preferences,
config: SentryConfig(
dsn: dsn,
release: 'buzz@1.2.3',
dist: '42',
environment: 'production',
),
crashReporter: reporter,
log: (_) {},
);
}
Future<void> pumpSettings(
WidgetTester tester,
DiagnosticsController controller,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
diagnosticsControllerProvider.overrideWith((_) => controller),
savedPrefsProvider.overrideWithValue(preferences),
relayConfigProvider.overrideWith(() => _TestRelayConfigNotifier()),
],
child: MaterialApp(theme: AppTheme.light(), home: const SettingsPage()),
),
);
await tester.pump();
}
testWidgets('diagnostics switch enables crash reporting', (tester) async {
final controller = await createController();
await pumpSettings(tester, controller);
expect(find.text('DIAGNOSTICS'), findsOneWidget);
expect(find.text('Share Crash Reports'), findsOneWidget);
final toggle = tester.widget<Switch>(find.byType(Switch));
expect(toggle.value, isFalse);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
expect(tester.widget<Switch>(find.byType(Switch)).value, isTrue);
expect(controller.consentGranted, isTrue);
expect(preferences.getBool(diagnosticsConsentPreferenceKey), isTrue);
expect(reporter.initializeCalls, 1);
});
testWidgets('diagnostics switch disables and closes crash reporting', (
tester,
) async {
final controller = await createController(consent: true);
await controller.applyStartupConsent();
await pumpSettings(tester, controller);
expect(tester.widget<Switch>(find.byType(Switch)).value, isTrue);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
expect(tester.widget<Switch>(find.byType(Switch)).value, isFalse);
expect(controller.consentGranted, isFalse);
expect(reporter.closeCalls, 1);
});
testWidgets('reports and rolls back an initialization failure', (
tester,
) async {
final controller = await createController();
reporter.initializeError = StateError('init failed');
await pumpSettings(tester, controller);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
expect(
find.textContaining('Could not update crash reporting:'),
findsOneWidget,
);
expect(tester.widget<Switch>(find.byType(Switch)).value, isFalse);
expect(controller.consentGranted, isFalse);
expect(preferences.getBool(diagnosticsConsentPreferenceKey), isFalse);
});
testWidgets('existing consent remains visible in an unconfigured build', (
tester,
) async {
final controller = await createController(consent: true, dsn: '');
await controller.applyStartupConsent();
await pumpSettings(tester, controller);
final toggle = tester.widget<Switch>(find.byType(Switch));
expect(toggle.value, isTrue);
expect(toggle.onChanged, isNotNull);
await tester.tap(find.byType(Switch));
await tester.pumpAndSettle();
expect(controller.consentGranted, isFalse);
expect(preferences.getBool(diagnosticsConsentPreferenceKey), isFalse);
expect(reporter.initializeCalls, 0);
});
testWidgets('diagnostics switch is disabled without a release DSN', (
tester,
) async {
final controller = await createController(dsn: '');
await pumpSettings(tester, controller);
expect(
find.text('Crash reporting is unavailable in this build.'),
findsOneWidget,
);
expect(tester.widget<Switch>(find.byType(Switch)).onChanged, isNull);
await tester.tap(find.byType(Switch), warnIfMissed: false);
await tester.pumpAndSettle();
expect(controller.consentGranted, isFalse);
expect(preferences.getBool(diagnosticsConsentPreferenceKey), isFalse);
expect(reporter.initializeCalls, 0);
});
}
class _TestRelayConfigNotifier extends RelayConfigNotifier {
@override
RelayConfig build() =>
const RelayConfig(baseUrl: 'https://relay.example.invalid');
}
class _RecordingCrashReporter implements CrashReporter {
int initializeCalls = 0;
int closeCalls = 0;
Object? initializeError;
@override
Future<void> initialize(SentryConfig config) async {
initializeCalls += 1;
if (initializeError case final error?) {
throw error;
}
}
@override
Future<void> close() async {
closeCalls += 1;
}
}
@@ -0,0 +1,245 @@
import 'dart:async';
import 'package:buzz/shared/diagnostics/diagnostics.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
late SharedPreferences preferences;
late _RecordingCrashReporter reporter;
late List<String> logs;
Future<DiagnosticsController> createController({
Map<String, Object> storedValues = const {},
String dsn = 'https://public@example.invalid/1',
}) async {
SharedPreferences.setMockInitialValues(storedValues);
preferences = await SharedPreferences.getInstance();
reporter = _RecordingCrashReporter();
logs = [];
return DiagnosticsController(
preferences: preferences,
config: SentryConfig(
dsn: dsn,
release: 'buzz@1.2.3',
dist: '42',
environment: 'production',
),
crashReporter: reporter,
log: logs.add,
);
}
test(
'defaults consent to false and does not initialize at startup',
() async {
final controller = await createController();
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: ' ',
);
await controller.applyStartupConsent();
expect(controller.consentGranted, isTrue);
expect(controller.isConfigured, isFalse);
expect(reporter.initializeCalls, 0);
expect(
logs,
contains(
'Diagnostics disabled: SENTRY_DSN is empty; Sentry not initialized.',
),
);
});
test('cannot enable consent in an unconfigured build', () async {
final controller = await createController(dsn: ' ');
await expectLater(controller.setConsent(true), throwsA(isA<StateError>()));
expect(controller.consentGranted, isFalse);
expect(preferences.getBool(diagnosticsConsentPreferenceKey), isNull);
expect(reporter.initializeCalls, 0);
expect(
logs,
contains(
'Diagnostics unchanged: SENTRY_DSN is empty; consent not enabled.',
),
);
});
test('stored consent initializes once at startup', () async {
final controller = await createController(
storedValues: {diagnosticsConsentPreferenceKey: true},
);
await controller.applyStartupConsent();
await controller.applyStartupConsent();
expect(reporter.initializeCalls, 1);
expect(reporter.config?.release, 'buzz@1.2.3');
expect(reporter.config?.dist, '42');
expect(
logs,
contains('Diagnostics already enabled: Sentry initialization skipped.'),
);
});
test('enabling persists consent and initializes immediately', () async {
final controller = await createController();
await controller.setConsent(true);
expect(controller.consentGranted, isTrue);
expect(preferences.getBool(diagnosticsConsentPreferenceKey), isTrue);
expect(reporter.initializeCalls, 1);
expect(
logs,
contains('Diagnostics enabled: Sentry initialized after user consent.'),
);
});
test('disabling persists consent and closes immediately', () async {
final controller = await createController(
storedValues: {diagnosticsConsentPreferenceKey: true},
);
await controller.applyStartupConsent();
await controller.setConsent(false);
expect(controller.consentGranted, isFalse);
expect(preferences.getBool(diagnosticsConsentPreferenceKey), isFalse);
expect(reporter.closeCalls, 1);
expect(
logs,
contains('Diagnostics disabled: user consent is off; Sentry closed.'),
);
});
test('can reinitialize after disabling', () async {
final controller = await createController(
storedValues: {diagnosticsConsentPreferenceKey: true},
);
await controller.applyStartupConsent();
await controller.setConsent(false);
await controller.setConsent(true);
expect(reporter.initializeCalls, 2);
expect(reporter.closeCalls, 1);
});
test('serializes concurrent consent changes', () async {
final controller = await createController();
reporter.initializeGate = Completer<void>();
final enable = controller.setConsent(true);
await reporter.initializeStarted.future;
final disable = controller.setConsent(false);
expect(reporter.closeCalls, 0);
reporter.initializeGate!.complete();
await Future.wait([enable, disable]);
expect(controller.consentGranted, isFalse);
expect(preferences.getBool(diagnosticsConsentPreferenceKey), isFalse);
expect(reporter.initializeCalls, 1);
expect(reporter.closeCalls, 1);
});
test('initialization failure rolls back consent', () async {
final controller = await createController();
reporter.initializeError = StateError('init failed');
await expectLater(controller.setConsent(true), throwsA(isA<StateError>()));
expect(controller.consentGranted, isFalse);
expect(preferences.getBool(diagnosticsConsentPreferenceKey), isFalse);
expect(reporter.initializeCalls, 1);
});
test('close failure rolls back revocation', () async {
final controller = await createController(
storedValues: {diagnosticsConsentPreferenceKey: true},
);
await controller.applyStartupConsent();
reporter.closeError = StateError('close failed');
await expectLater(controller.setConsent(false), throwsA(isA<StateError>()));
expect(controller.consentGranted, isTrue);
expect(preferences.getBool(diagnosticsConsentPreferenceKey), isTrue);
expect(reporter.closeCalls, 1);
});
test('continues accepting changes after a failed operation', () async {
final controller = await createController();
reporter.initializeError = StateError('init failed');
await expectLater(controller.setConsent(true), throwsA(isA<StateError>()));
reporter.initializeError = null;
await controller.setConsent(true);
expect(controller.consentGranted, isTrue);
expect(preferences.getBool(diagnosticsConsentPreferenceKey), isTrue);
expect(reporter.initializeCalls, 2);
});
test('repeating the current value is idempotent', () async {
final controller = await createController();
await controller.setConsent(false);
await controller.setConsent(true);
await controller.setConsent(true);
expect(reporter.initializeCalls, 1);
expect(reporter.closeCalls, 0);
});
}
class _RecordingCrashReporter implements CrashReporter {
int initializeCalls = 0;
int closeCalls = 0;
Object? initializeError;
Object? closeError;
Completer<void>? initializeGate;
final initializeStarted = Completer<void>();
SentryConfig? config;
@override
Future<void> initialize(SentryConfig config) async {
initializeCalls += 1;
this.config = config;
if (!initializeStarted.isCompleted) {
initializeStarted.complete();
}
await initializeGate?.future;
if (initializeError case final error?) {
throw error;
}
}
@override
Future<void> close() async {
closeCalls += 1;
if (closeError case final error?) {
throw error;
}
}
}
@@ -0,0 +1,91 @@
// Sentry marks these stable configuration fields as experimental.
// ignore_for_file: experimental_member_use
import 'package:buzz/shared/diagnostics/diagnostics.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
void main() {
test('empty DSN is not configured', () {
const config = SentryConfig(
dsn: ' ',
release: '',
dist: '',
environment: '',
);
expect(config.isConfigured, isFalse);
});
test('omits blank optional release metadata', () {
const config = SentryConfig(
dsn: 'https://public@example.invalid/1',
release: ' ',
dist: '',
environment: '\n',
);
final options = SentryFlutterOptions();
config.applyTo(options);
expect(options.release, isNull);
expect(options.dist, isNull);
expect(options.environment, isNull);
});
test('applies crash-only privacy configuration', () {
const config = SentryConfig(
dsn: ' https://public@example.invalid/1 ',
release: ' buzz@1.2.3 ',
dist: ' 42 ',
environment: ' production ',
);
final options = SentryFlutterOptions();
config.applyTo(options);
expect(options.dsn, 'https://public@example.invalid/1');
expect(options.release, 'buzz@1.2.3');
expect(options.dist, '42');
expect(options.environment, 'production');
expect(options.sendDefaultPii, isFalse);
expect(options.markAutomaticallyCollectedErrorsAsFatal, isTrue);
expect(options.attachScreenshot, isFalse);
expect(options.attachViewHierarchy, isFalse);
expect(options.reportViewHierarchyIdentifiers, isFalse);
expect(options.enableAutoSessionTracking, isFalse);
expect(options.enableWatchdogTerminationTracking, isFalse);
expect(options.enableAppHangTracking, isFalse);
expect(options.anrEnabled, isFalse);
expect(options.enableTombstone, isFalse);
expect(options.enableNdkScopeSync, isFalse);
expect(options.enableLogs, isFalse);
expect(options.enableMetrics, isFalse);
expect(options.reportPackages, isFalse);
expect(options.sendClientReports, isFalse);
expect(options.maxBreadcrumbs, 0);
expect(options.tracesSampleRate, 0);
expect(options.profilesSampleRate, 0);
expect(options.enableAutoPerformanceTracing, isFalse);
expect(options.enableUserInteractionTracing, isFalse);
expect(options.enableUserInteractionBreadcrumbs, isFalse);
expect(options.enableTimeToFullDisplayTracing, isFalse);
expect(options.enableFramesTracking, isFalse);
expect(options.enableNativeTraceSync, isFalse);
expect(options.enablePrintBreadcrumbs, isFalse);
expect(options.enableAutoNativeBreadcrumbs, isFalse);
expect(options.enableAppLifecycleBreadcrumbs, isFalse);
expect(options.enableWindowMetricBreadcrumbs, isFalse);
expect(options.enableBrightnessChangeBreadcrumbs, isFalse);
expect(options.enableTextScaleChangeBreadcrumbs, isFalse);
expect(options.enableMemoryPressureBreadcrumbs, isFalse);
expect(options.recordHttpBreadcrumbs, isFalse);
expect(options.captureFailedRequests, isFalse);
expect(options.captureNativeFailedRequests, isFalse);
expect(options.maxRequestBodySize, MaxRequestBodySize.never);
expect(options.replay.sessionSampleRate, 0);
expect(options.replay.onErrorSampleRate, 0);
expect(options.privacy.maskAllText, isTrue);
expect(options.privacy.maskAllImages, isTrue);
});
}