Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions mobile/ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions mobile/lib/features/settings/settings_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);

Expand Down Expand Up @@ -106,6 +108,43 @@ class SettingsPage extends HookConsumerWidget {
],
),

// Diagnostics
AppListSection(
label: 'Diagnostics',
children: [
AppListRow(
icon: LucideIcons.activity,
title: 'Share crash reports',
subtitle: diagnostics.isConfigured
? 'Sent anonymously to help fix problems.'
: 'Crash reporting is unavailable in this build.',
subtitleMaxLines: 2,
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',
Expand Down
36 changes: 33 additions & 3 deletions mobile/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,48 @@ 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 {
Future<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 applyStartupDiagnosticsConsent(diagnosticsController);

runApp(
ProviderScope(
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
diagnosticsControllerProvider.overrideWith(
(_) => diagnosticsController,
),
],
child: const App(),
),
);
}

/// Applies diagnostics consent without allowing optional crash reporting to
/// prevent the app from starting.
@visibleForTesting
Future<void> applyStartupDiagnosticsConsent(
DiagnosticsController diagnosticsController, {
DiagnosticsLog? log,
}) async {
try {
await diagnosticsController.applyStartupConsent();
} on Object catch (error, stackTrace) {
(log ?? debugPrint)(
'Diagnostics startup failed; continuing without crash reporting: '
'$error\n$stackTrace',
);
}
}
3 changes: 3 additions & 0 deletions mobile/lib/shared/diagnostics/diagnostics.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export 'diagnostics_controller.dart';
export 'diagnostics_provider.dart';
export 'sentry_config.dart';
151 changes: 151 additions & 0 deletions mobile/lib/shared/diagnostics/diagnostics_controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
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';
const _diagnosticsEnabledByDefault = true;

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) ??
_diagnosticsEnabledByDefault;

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) {
Comment thread
brow marked this conversation as resolved.
// A failed close keeps the runtime initialized even though the
// persisted preference and visible control are already off. Allow the
// same revocation to retry teardown until it succeeds.
if (!granted && _initialized) {
await _applyCurrentConsent();
}
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;
var consentPersisted = false;
_consentGranted = granted;
notifyListeners();
try {
final persisted = await _preferences.setBool(
diagnosticsConsentPreferenceKey,
granted,
);
if (!persisted) {
throw StateError('Failed to persist diagnostics consent');
}
consentPersisted = true;
await _applyCurrentConsent();
} on Object {
// A persisted revocation must survive teardown failure. Otherwise the
// next launch could initialize crash reporting against the user's
// explicit choice.
if (!granted && consentPersisted) {
rethrow;
}
_consentGranted = previousConsent;
final rolledBack = await _preferences.setBool(
diagnosticsConsentPreferenceKey,
previousConsent,
);
notifyListeners();
if (!rolledBack) {
throw StateError('Failed to roll back diagnostics consent');
}
rethrow;
Comment thread
brow marked this conversation as resolved.
}
});
}

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;
}
}
9 changes: 9 additions & 0 deletions mobile/lib/shared/diagnostics/diagnostics_provider.dart
Original file line number Diff line number Diff line change
@@ -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'),
);
Loading
Loading