mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
parent
b1ef791cb8
commit
fb375ddf24
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user