From 738b62666abc4b3e8b12e81f91adf260762149e8 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 13:54:52 +0200 Subject: [PATCH 1/5] fix: trim Help menu and add safe area to server directory Remove IRC help, Support and Release audit from Settings (not part of the reference app) and their now-unused help texts. Show the server directory sheet below the status bar via useSafeArea. --- .../presentation/server_directory_picker.dart | 1 + .../presentation/settings_screen.dart | 66 ------------------- test/widget_test.dart | 48 ++++---------- 3 files changed, 15 insertions(+), 100 deletions(-) diff --git a/lib/features/connections/presentation/server_directory_picker.dart b/lib/features/connections/presentation/server_directory_picker.dart index 575247c..e45297a 100644 --- a/lib/features/connections/presentation/server_directory_picker.dart +++ b/lib/features/connections/presentation/server_directory_picker.dart @@ -29,6 +29,7 @@ Future showServerDirectoryPicker( final selected = await showModalBottomSheet( context: context, isScrollControlled: true, + useSafeArea: true, builder: (sheetContext) { return SafeArea( child: ListView( diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 2df60d6..d448271 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -704,17 +704,6 @@ class _SettingsScreenState extends State { _SettingsSection( title: 'Help', children: [ - ListTile( - key: const Key('settings-help-topic'), - leading: const Icon(Icons.help_outline), - title: const Text('IRC help'), - subtitle: const Text( - 'Connection, SASL, channel keys, DCC, and proxy notes.', - ), - onTap: () => - _showInfoDialog(title: 'IRC help', body: _helpText), - ), - const Divider(height: 1), ListTile( key: const Key('settings-privacy-topic'), leading: const Icon(Icons.privacy_tip_outlined), @@ -754,32 +743,6 @@ class _SettingsScreenState extends State { ), ), const Divider(height: 1), - ListTile( - key: const Key('settings-support-topic'), - leading: const Icon(Icons.support_agent_outlined), - title: const Text('Support'), - subtitle: const Text( - 'What to include when reporting a connection issue.', - ), - onTap: () => _showInfoDialog( - title: 'Support', - body: _supportText, - ), - ), - const Divider(height: 1), - ListTile( - key: const Key('settings-release-audit-topic'), - leading: const Icon(Icons.verified_outlined), - title: const Text('Release audit'), - subtitle: const Text( - 'Package, version, permissions, and signing gates.', - ), - onTap: () => _showInfoDialog( - title: 'Release audit', - body: _releaseAuditText, - ), - ), - const Divider(height: 1), ListTile( key: const Key('settings-crash-reports'), leading: const Icon(Icons.bug_report_outlined), @@ -1031,18 +994,6 @@ class _SettingsScreenState extends State { } } -const String _helpText = ''' -Use TLS where the network supports it. SASL PLAIN, SCRAM-SHA-256, and EXTERNAL are negotiated through IRCv3 CAP. - -NickServ fallback is only sent when SASL is configured but unavailable, rejected, or incomplete after registration. The fallback uses the SASL account and password and redacts the password from raw logs. - -Auto-join channel keys are stored with other network secrets and are redacted from public JSON and raw JOIN logs. - -DCC SEND and CHAT run through foreground transfer state. Reverse/passive DCC support depends on the other client and the network path. - -SOCKS5 proxy mode sends the IRC host name to the proxy for remote DNS, which is required for Tor-style routing. -'''; - const String _privacyText = ''' Network passwords, SASL passwords, proxy passwords, and auto-join channel keys are stored through the configured SecretStorage backend. @@ -1051,23 +1002,6 @@ IRC messages are sent to the networks you connect to. DCC transfers connect dire The app does not include ads, analytics, crash reporting, WebRTC calls, scripting, or E2EE in the current release slice. '''; -const String _supportText = ''' -For connection issues, include the network host, port, TLS setting, SASL mechanism, proxy setting, Android version, and the redacted raw server-tab log. - -Do not send server passwords, SASL passwords, proxy passwords, channel keys, private keys, or downloaded file paths. -'''; - -const String _releaseAuditText = ''' -Android package: com.androidircx.flutter -Version source: pubspec.yaml - -Permissions: INTERNET, ACCESS_NETWORK_STATE, FOREGROUND_SERVICE, FOREGROUND_SERVICE_REMOTE_MESSAGING, POST_NOTIFICATIONS. - -Release signing: android/key.properties is used when present. Local builds fall back to debug signing and are not Play Store upload artifacts. - -Device smoke gates: background connection runtime, multi-network foreground service, DCC transfer lifetime, notifications, and proxy/Tor connection. -'''; - class _SettingsSection extends StatelessWidget { const _SettingsSection({required this.title, required this.children}); diff --git a/test/widget_test.dart b/test/widget_test.dart index caeb0bc..349469b 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -726,34 +726,20 @@ void main() { expect(settings.nickColorMode, NickColorMode.vivid); }); - testWidgets('settings shows help privacy support and release audit docs', ( - tester, - ) async { + testWidgets('settings shows the privacy doc', (tester) async { SharedPreferences.setMockInitialValues({}); await tester.pumpWidget(const MaterialApp(home: SettingsScreen())); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); - final settingsScrollable = find.byType(Scrollable).first; - Future scrollTo(String key) async { - await tester.scrollUntilVisible( - find.byKey(Key(key)), - 200, - scrollable: settingsScrollable, - ); - await tester.pumpAndSettle(); - } - - await scrollTo('settings-help-topic'); - await tester.tap(find.byKey(const Key('settings-help-topic'))); - await tester.pumpAndSettle(); - expect(find.text('IRC help'), findsWidgets); - expect(find.textContaining('NickServ fallback'), findsOneWidget); - await tester.tap(find.text('Close')); + await tester.scrollUntilVisible( + find.byKey(const Key('settings-privacy-topic')), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.ensureVisible(find.byKey(const Key('settings-privacy-topic'))); await tester.pumpAndSettle(); - - await scrollTo('settings-privacy-topic'); await tester.tap(find.byKey(const Key('settings-privacy-topic'))); await tester.pumpAndSettle(); expect(find.text('Privacy'), findsWidgets); @@ -761,19 +747,13 @@ void main() { await tester.tap(find.text('Close')); await tester.pumpAndSettle(); - await scrollTo('settings-support-topic'); - await tester.tap(find.byKey(const Key('settings-support-topic'))); - await tester.pumpAndSettle(); - expect(find.text('Support'), findsWidgets); - expect(find.textContaining('redacted raw server-tab log'), findsOneWidget); - await tester.tap(find.text('Close')); - await tester.pumpAndSettle(); - - await scrollTo('settings-release-audit-topic'); - await tester.tap(find.byKey(const Key('settings-release-audit-topic'))); - await tester.pumpAndSettle(); - expect(find.text('Release audit'), findsWidgets); - expect(find.textContaining('com.androidircx.flutter'), findsOneWidget); + // IRC help, Support and Release audit were removed from the menu. + expect(find.byKey(const Key('settings-help-topic')), findsNothing); + expect(find.byKey(const Key('settings-support-topic')), findsNothing); + expect( + find.byKey(const Key('settings-release-audit-topic')), + findsNothing, + ); }); testWidgets('shows IRC services quick actions on the server tab', ( From 747b0ddf75e9916ff5eae72a79bd90d82709c2c0 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 14:07:16 +0200 Subject: [PATCH 2/5] feat: gate notifications and camera behind runtime permissions Add a master 'Enable notifications' switch that requests POST_NOTIFICATIONS before turning on (default off, per-type toggles gated on it) and reconciles to off if the OS permission is revoked. Add a Camera access permission tile. Only the ongoing background-connection notice is shown without opt-in, so the app stays reachable once notifications are granted. Wire permission_handler and declare CAMERA. --- android/app/src/main/AndroidManifest.xml | 1 + lib/core/models/app_settings.dart | 9 + lib/core/platform/app_permissions.dart | 53 ++++++ .../application/chat_session_controller.dart | 10 ++ .../presentation/settings_screen.dart | 166 ++++++++++++++++-- pubspec.lock | 48 +++++ pubspec.yaml | 1 + test/chat_session_controller_test.dart | 10 +- ...notification_permission_settings_test.dart | 138 +++++++++++++++ test/session_registry_test.dart | 14 ++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 12 files changed, 438 insertions(+), 16 deletions(-) create mode 100644 lib/core/platform/app_permissions.dart create mode 100644 test/notification_permission_settings_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ca15fd6..51af3d4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + requestNotifications(); + Future hasNotifications(); + Future requestCamera(); + Future hasCamera(); + + /// Opens the OS app-settings page (used after a permanent denial). + Future openSettingsPage(); +} + +class PermissionHandlerAppPermissions implements AppPermissions { + const PermissionHandlerAppPermissions(); + + static AppPermissionResult _map(PermissionStatus status) { + if (status.isGranted || status.isLimited) { + return AppPermissionResult.granted; + } + if (status.isPermanentlyDenied) { + return AppPermissionResult.permanentlyDenied; + } + if (status.isRestricted) { + return AppPermissionResult.restricted; + } + return AppPermissionResult.denied; + } + + @override + Future requestNotifications() async => + _map(await Permission.notification.request()); + + @override + Future hasNotifications() async => + (await Permission.notification.status).isGranted; + + @override + Future requestCamera() async => + _map(await Permission.camera.request()); + + @override + Future hasCamera() async => + (await Permission.camera.status).isGranted; + + @override + Future openSettingsPage() async { + await openAppSettings(); + } +} diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index 3e3509c..e75a1f5 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -5436,6 +5436,16 @@ class ChatSessionController extends ChangeNotifier { } bool _notificationEnabledFor(ForegroundNotificationChannelKind kind) { + // The ongoing connection/foreground-service notice is always attempted so + // the app is reachable from the background; the OS shows it once the user + // has granted notification permission. + if (kind == ForegroundNotificationChannelKind.connection) { + return true; + } + // Every other alert requires the user to have opted into notifications. + if (!_settings.notificationsEnabled) { + return false; + } switch (kind) { case ForegroundNotificationChannelKind.highlights: return _settings.notifyHighlights; diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index d448271..8b68e3a 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:androidircx/app/theme/app_theme.dart'; import 'package:androidircx/core/models/app_settings.dart'; +import 'package:androidircx/core/platform/app_permissions.dart'; import 'package:androidircx/core/presets/server_preset_service.dart'; import 'package:androidircx/core/settings/app_settings_controller.dart'; import 'package:androidircx/core/storage/settings_repository.dart'; @@ -23,6 +26,7 @@ class SettingsScreen extends StatefulWidget { this.networkController, this.presetService, this.appLockAuthenticator, + this.permissions, }); final SettingsRepository? repository; @@ -37,6 +41,10 @@ class SettingsScreen extends StatefulWidget { /// for tests; defaults to a biometric/PIN prompt. final Future Function()? appLockAuthenticator; + /// Runtime OS permissions (notifications, camera). Overridable for tests; + /// defaults to the `permission_handler` backed implementation. + final AppPermissions? permissions; + @override State createState() => _SettingsScreenState(); } @@ -52,6 +60,10 @@ class _SettingsScreenState extends State { AppSettings _settings = const AppSettings(); bool _isLoading = true; bool _didResolveController = false; + bool _cameraGranted = false; + + AppPermissions get _permissions => + widget.permissions ?? const PermissionHandlerAppPermissions(); @override void initState() { @@ -75,6 +87,7 @@ class _SettingsScreenState extends State { } controller.addListener(_syncFromController); _syncFromController(); + unawaited(_refreshPermissionStatuses()); } @override @@ -458,50 +471,94 @@ class _SettingsScreenState extends State { _SettingsSection( title: 'Notifications', children: [ + SwitchListTile( + key: const Key('settings-notifications-enabled'), + secondary: const Icon( + Icons.notifications_active_outlined, + ), + title: const Text('Enable notifications'), + subtitle: const Text( + 'Ask Android for permission, then show alerts and the ' + 'background connection notice.', + ), + value: _settings.notificationsEnabled, + onChanged: (value) => _toggleNotifications(value), + ), + const Divider(height: 1), SwitchListTile( key: const Key('settings-notify-highlights'), title: const Text('Highlights'), subtitle: const Text('Your nick or highlight words.'), value: _settings.notifyHighlights, - onChanged: (value) => _saveSettings( - _settings.copyWith(notifyHighlights: value), - ), + onChanged: _settings.notificationsEnabled + ? (value) => _saveSettings( + _settings.copyWith(notifyHighlights: value), + ) + : null, ), const Divider(height: 1), SwitchListTile( key: const Key('settings-notify-pm'), title: const Text('Private messages'), value: _settings.notifyPrivateMessages, - onChanged: (value) => _saveSettings( - _settings.copyWith(notifyPrivateMessages: value), - ), + onChanged: _settings.notificationsEnabled + ? (value) => _saveSettings( + _settings.copyWith(notifyPrivateMessages: value), + ) + : null, ), const Divider(height: 1), SwitchListTile( key: const Key('settings-notify-dcc'), title: const Text('DCC offers'), value: _settings.notifyDccOffers, - onChanged: (value) => _saveSettings( - _settings.copyWith(notifyDccOffers: value), - ), + onChanged: _settings.notificationsEnabled + ? (value) => _saveSettings( + _settings.copyWith(notifyDccOffers: value), + ) + : null, ), const Divider(height: 1), SwitchListTile( key: const Key('settings-notify-errors'), title: const Text('Errors'), value: _settings.notifyErrors, - onChanged: (value) => _saveSettings( - _settings.copyWith(notifyErrors: value), - ), + onChanged: _settings.notificationsEnabled + ? (value) => _saveSettings( + _settings.copyWith(notifyErrors: value), + ) + : null, ), const Divider(height: 1), SwitchListTile( key: const Key('settings-notify-sound'), title: const Text('Notification sound'), value: _settings.notificationSound, - onChanged: (value) => _saveSettings( - _settings.copyWith(notificationSound: value), + onChanged: _settings.notificationsEnabled + ? (value) => _saveSettings( + _settings.copyWith(notificationSound: value), + ) + : null, + ), + ], + ), + const SizedBox(height: 12), + _SettingsSection( + title: 'Permissions', + children: [ + ListTile( + key: const Key('settings-permission-camera'), + leading: const Icon(Icons.photo_camera_outlined), + title: const Text('Camera access'), + subtitle: Text( + _cameraGranted + ? 'Granted — you can capture photos and video.' + : 'Needed to capture photos/video for media and DCC.', ), + trailing: _cameraGranted + ? const Icon(Icons.check_circle_outline) + : const Text('Grant'), + onTap: _cameraGranted ? null : _requestCameraPermission, ), ], ), @@ -774,6 +831,7 @@ class _SettingsScreenState extends State { _isLoading = false; _syncTextControllers(settings); }); + await _refreshPermissionStatuses(); } void _syncFromController() { @@ -876,6 +934,86 @@ class _SettingsScreenState extends State { } } + /// Reconciles permission-gated settings on entry: notifications can only be + /// on while the OS permission is granted, and refreshes the camera status. + Future _refreshPermissionStatuses() async { + final hasNotifications = await _permissions.hasNotifications(); + final hasCamera = await _permissions.hasCamera(); + if (!mounted) { + return; + } + if (_settings.notificationsEnabled && !hasNotifications) { + await _saveSettings(_settings.copyWith(notificationsEnabled: false)); + } + if (mounted && hasCamera != _cameraGranted) { + setState(() => _cameraGranted = hasCamera); + } + } + + Future _toggleNotifications(bool value) async { + if (!value) { + await _saveSettings(_settings.copyWith(notificationsEnabled: false)); + return; + } + // Turning on requests the OS notification permission first; only enable on + // grant so the toggles reflect what Android will actually deliver. + if (await _permissions.hasNotifications()) { + await _saveSettings(_settings.copyWith(notificationsEnabled: true)); + return; + } + final result = await _permissions.requestNotifications(); + if (!mounted) { + return; + } + if (result == AppPermissionResult.granted) { + await _saveSettings(_settings.copyWith(notificationsEnabled: true)); + return; + } + final permanentlyDenied = result == AppPermissionResult.permanentlyDenied; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + permanentlyDenied + ? 'Notifications are blocked. Enable them in system settings.' + : 'Notification permission denied — notifications stay off.', + ), + action: permanentlyDenied + ? SnackBarAction( + label: 'Settings', + onPressed: () => unawaited(_permissions.openSettingsPage()), + ) + : null, + ), + ); + } + + Future _requestCameraPermission() async { + final result = await _permissions.requestCamera(); + if (!mounted) { + return; + } + if (result == AppPermissionResult.granted) { + setState(() => _cameraGranted = true); + return; + } + final permanentlyDenied = result == AppPermissionResult.permanentlyDenied; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + permanentlyDenied + ? 'Camera is blocked. Enable it in system settings.' + : 'Camera permission denied.', + ), + action: permanentlyDenied + ? SnackBarAction( + label: 'Settings', + onPressed: () => unawaited(_permissions.openSettingsPage()), + ) + : null, + ), + ); + } + Future _toggleAppLock(bool value) async { // Turning off is immediate. Turning on first confirms the user can actually // authenticate, so enabling it can never lock them out of the app. diff --git a/pubspec.lock b/pubspec.lock index ba01af8..a6b88fc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -744,6 +744,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8 + url: "https://pub.dev" + source: hosted + version: "9.6.1" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" + url: "https://pub.dev" + source: hosted + version: "0.1.4+1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23 + url: "https://pub.dev" + source: hosted + version: "4.4.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd + url: "https://pub.dev" + source: hosted + version: "0.2.2" platform: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f67f5f0..d710acb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,7 @@ dependencies: image_picker: ^1.2.3 in_app_review: ^2.0.12 video_player: ^2.9.2 + permission_handler: ^11.3.1 dev_dependencies: flutter_test: diff --git a/test/chat_session_controller_test.dart b/test/chat_session_controller_test.dart index 6119d8a..8a6ed1e 100644 --- a/test/chat_session_controller_test.dart +++ b/test/chat_session_controller_test.dart @@ -2869,7 +2869,10 @@ void main() { ), ircService: service, settingsRepository: _FakeSettingsRepository( - const AppSettings(highlightWords: ['flutter']), + const AppSettings( + highlightWords: ['flutter'], + notificationsEnabled: true, + ), ), ); @@ -2905,7 +2908,10 @@ void main() { ), ircService: service, settingsRepository: _FakeSettingsRepository( - const AppSettings(notifyPrivateMessages: false), + const AppSettings( + notifyPrivateMessages: false, + notificationsEnabled: true, + ), ), ); diff --git a/test/notification_permission_settings_test.dart b/test/notification_permission_settings_test.dart new file mode 100644 index 0000000..ecc00a3 --- /dev/null +++ b/test/notification_permission_settings_test.dart @@ -0,0 +1,138 @@ +import 'package:androidircx/core/models/app_settings.dart'; +import 'package:androidircx/core/platform/app_permissions.dart'; +import 'package:androidircx/core/storage/shared_prefs_settings_repository.dart'; +import 'package:androidircx/features/settings/presentation/settings_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakePermissions implements AppPermissions { + _FakePermissions({ + this.notifResult = AppPermissionResult.granted, + this.camResult = AppPermissionResult.granted, + this.hasNotif = false, + }); + + AppPermissionResult notifResult; + AppPermissionResult camResult; + bool hasNotif; + bool hasCam = false; + int notifRequests = 0; + int camRequests = 0; + + @override + Future hasNotifications() async => hasNotif; + + @override + Future hasCamera() async => hasCam; + + @override + Future requestNotifications() async { + notifRequests++; + if (notifResult == AppPermissionResult.granted) hasNotif = true; + return notifResult; + } + + @override + Future requestCamera() async { + camRequests++; + if (camResult == AppPermissionResult.granted) hasCam = true; + return camResult; + } + + @override + Future openSettingsPage() async {} +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future pump(WidgetTester tester, _FakePermissions perms) async { + await tester.pumpWidget( + MaterialApp(home: SettingsScreen(permissions: perms)), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.scrollUntilVisible( + find.byKey(const Key('settings-notifications-enabled')), + 250, + scrollable: find.byType(Scrollable).first, + ); + await tester.ensureVisible( + find.byKey(const Key('settings-notifications-enabled')), + ); + await tester.pumpAndSettle(); + } + + testWidgets('enabling notifications requests permission and enables on grant', + (tester) async { + final perms = _FakePermissions(notifResult: AppPermissionResult.granted); + await pump(tester, perms); + + await tester.tap(find.byKey(const Key('settings-notifications-enabled'))); + await tester.pumpAndSettle(); + + expect(perms.notifRequests, 1); + final saved = await SharedPrefsSettingsRepository().loadSettings(); + expect(saved.notificationsEnabled, isTrue); + }); + + testWidgets('denied permission keeps notifications off', (tester) async { + final perms = _FakePermissions(notifResult: AppPermissionResult.denied); + await pump(tester, perms); + + await tester.tap(find.byKey(const Key('settings-notifications-enabled'))); + await tester.pumpAndSettle(); + + expect(perms.notifRequests, 1); + final saved = await SharedPrefsSettingsRepository().loadSettings(); + expect(saved.notificationsEnabled, isFalse); + expect( + find.textContaining('Notification permission denied'), + findsOneWidget, + ); + }); + + testWidgets('reconciles notifications off when OS permission is missing', + (tester) async { + // Stored as enabled, but the OS permission is not granted. + await SharedPrefsSettingsRepository() + .saveSettings(const AppSettings(notificationsEnabled: true)); + final perms = _FakePermissions(hasNotif: false); + + await tester.pumpWidget( + MaterialApp(home: SettingsScreen(permissions: perms)), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + final saved = await SharedPrefsSettingsRepository().loadSettings(); + expect(saved.notificationsEnabled, isFalse); + }); + + testWidgets('granting camera permission shows granted state', (tester) async { + final perms = _FakePermissions(camResult: AppPermissionResult.granted); + await tester.pumpWidget( + MaterialApp(home: SettingsScreen(permissions: perms)), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.scrollUntilVisible( + find.byKey(const Key('settings-permission-camera')), + 250, + scrollable: find.byType(Scrollable).first, + ); + await tester.ensureVisible( + find.byKey(const Key('settings-permission-camera')), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('settings-permission-camera'))); + await tester.pumpAndSettle(); + + expect(perms.camRequests, 1); + expect(find.text('Granted — you can capture photos and video.'), + findsOneWidget); + }); +} diff --git a/test/session_registry_test.dart b/test/session_registry_test.dart index f72abda..a18440a 100644 --- a/test/session_registry_test.dart +++ b/test/session_registry_test.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'package:androidircx/core/models/app_settings.dart'; import 'package:androidircx/core/models/connection_state.dart'; import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/core/storage/settings_repository.dart'; import 'package:androidircx/core/platform/foreground_connection_service.dart'; import 'package:androidircx/features/chat/application/chat_session_controller.dart'; import 'package:androidircx/features/chat/application/session_registry.dart'; @@ -235,6 +237,7 @@ void main() { network: network, ircService: IrcService(transportConnector: (_) async => transport), reconnectJitterFactor: 0, + settingsRepository: const _NotificationsOnSettingsRepository(), ), ); const network = NetworkConfig( @@ -361,3 +364,14 @@ void main() { registry.dispose(); }); } + +class _NotificationsOnSettingsRepository implements SettingsRepository { + const _NotificationsOnSettingsRepository(); + + @override + Future loadSettings() async => + const AppSettings(notificationsEnabled: true); + + @override + Future saveSettings(AppSettings settings) async {} +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 510fad9..4ae873f 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); LocalAuthPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("LocalAuthPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 336280b..5def12b 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows flutter_secure_storage_windows local_auth_windows + permission_handler_windows url_launcher_windows ) From e56dafdfe618cbdc3336af0d14235c1d779c04f4 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 14:32:30 +0200 Subject: [PATCH 3/5] feat: prompt for notification permission during onboarding Add a final onboarding step that requests POST_NOTIFICATIONS and, on grant, turns on the notifications master setting so highlights, private messages and the background-connection notice work without a trip to Settings. --- .../presentation/onboarding_screen.dart | 103 ++++++++++++++++- test/onboarding_permission_test.dart | 105 ++++++++++++++++++ test/widget_test.dart | 7 +- 3 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 test/onboarding_permission_test.dart diff --git a/lib/features/onboarding/presentation/onboarding_screen.dart b/lib/features/onboarding/presentation/onboarding_screen.dart index 64b1959..ebfa264 100644 --- a/lib/features/onboarding/presentation/onboarding_screen.dart +++ b/lib/features/onboarding/presentation/onboarding_screen.dart @@ -1,5 +1,8 @@ import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/core/platform/app_permissions.dart'; import 'package:androidircx/core/storage/network_repository.dart'; +import 'package:androidircx/core/storage/settings_repository.dart'; +import 'package:androidircx/core/storage/shared_prefs_settings_repository.dart'; import 'package:androidircx/features/onboarding/presentation/data_privacy_screen.dart'; import 'package:flutter/material.dart'; @@ -13,11 +16,19 @@ class OnboardingScreen extends StatefulWidget { super.key, required this.networkRepository, required this.onCompleted, + this.permissions, + this.settingsRepository, }); final NetworkRepository networkRepository; final Future Function() onCompleted; + /// Runtime OS permissions; injectable for tests. + final AppPermissions? permissions; + + /// Where the notification opt-in is persisted; injectable for tests. + final SettingsRepository? settingsRepository; + @override State createState() => _OnboardingScreenState(); } @@ -28,6 +39,13 @@ class _OnboardingScreenState extends State { int _step = 0; bool _consentAccepted = false; bool _saving = false; + bool _notificationsAsked = false; + bool _notificationsGranted = false; + + AppPermissions get _permissions => + widget.permissions ?? const PermissionHandlerAppPermissions(); + SettingsRepository get _settingsRepository => + widget.settingsRepository ?? SharedPrefsSettingsRepository(); final _nickname = TextEditingController(text: 'AndroidIRCX'); final _altNick = TextEditingController(text: 'AndroidIRCX_'); @@ -47,6 +65,7 @@ class _OnboardingScreenState extends State { 'Set up your identity', 'Choose your network', 'Choose your channels', + 'Notifications', ]; @override @@ -228,11 +247,93 @@ class _OnboardingScreenState extends State { return _buildIdentity(context); case 3: return _buildNetworkStep(context); - default: + case 4: return _buildChannels(context); + default: + return _buildPermissions(context); } } + Future _requestOnboardingNotifications() async { + final result = await _permissions.requestNotifications(); + if (!mounted) { + return; + } + final granted = result == AppPermissionResult.granted; + if (granted) { + try { + final settings = await _settingsRepository.loadSettings(); + await _settingsRepository.saveSettings( + settings.copyWith(notificationsEnabled: true), + ); + } catch (_) { + // Best effort; the user can still enable it in Settings. + } + } + if (mounted) { + setState(() { + _notificationsAsked = true; + _notificationsGranted = granted; + }); + } + } + + Widget _buildPermissions(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.notifications_active_outlined, + size: 56, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 16), + Text('Stay reachable in the background', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Allow notifications so highlights and private messages can alert you, ' + 'and so AndroidIRCX can show an ongoing notice while it keeps your ' + 'connection alive in the background. You can fine-tune or turn these ' + 'off any time in Settings.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 20), + if (_notificationsAsked) + Row( + children: [ + Icon( + _notificationsGranted + ? Icons.check_circle_outline + : Icons.info_outline, + color: _notificationsGranted + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _notificationsGranted + ? 'Notifications enabled.' + : 'No problem — you can enable notifications later in Settings.', + ), + ), + ], + ) + else + Align( + alignment: Alignment.centerLeft, + child: FilledButton.icon( + key: const Key('onboarding-allow-notifications'), + onPressed: () => _requestOnboardingNotifications(), + icon: const Icon(Icons.notifications_active_outlined), + label: const Text('Allow notifications'), + ), + ), + ], + ); + } + Widget _buildWelcome(BuildContext context) { final theme = Theme.of(context); return Column( diff --git a/test/onboarding_permission_test.dart b/test/onboarding_permission_test.dart new file mode 100644 index 0000000..89a065b --- /dev/null +++ b/test/onboarding_permission_test.dart @@ -0,0 +1,105 @@ +import 'package:androidircx/core/models/app_settings.dart'; +import 'package:androidircx/core/platform/app_permissions.dart'; +import 'package:androidircx/core/storage/in_memory_network_repository.dart'; +import 'package:androidircx/core/storage/settings_repository.dart'; +import 'package:androidircx/features/onboarding/presentation/onboarding_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakePermissions implements AppPermissions { + _FakePermissions(this.notifResult); + final AppPermissionResult notifResult; + int notifRequests = 0; + + @override + Future requestNotifications() async { + notifRequests++; + return notifResult; + } + + @override + Future hasNotifications() async => false; + @override + Future requestCamera() async => + AppPermissionResult.granted; + @override + Future hasCamera() async => false; + @override + Future openSettingsPage() async {} +} + +class _MemSettingsRepository implements SettingsRepository { + AppSettings settings = const AppSettings(); + @override + Future loadSettings() async => settings; + @override + Future saveSettings(AppSettings s) async => settings = s; +} + +Future _toNotificationsStep(WidgetTester tester) async { + await tester.tap(find.text('Next')); // welcome + await tester.pumpAndSettle(); + await tester.tap(find.byType(Checkbox)); // privacy consent + await tester.pumpAndSettle(); + await tester.tap(find.text('Next')); // privacy + await tester.pumpAndSettle(); + await tester.tap(find.text('Next')); // identity + await tester.pumpAndSettle(); + await tester.tap(find.text('Next')); // network + await tester.pumpAndSettle(); + await tester.tap(find.text('Next')); // channels -> notifications + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('granting notifications in onboarding enables the setting', ( + tester, + ) async { + final perms = _FakePermissions(AppPermissionResult.granted); + final settings = _MemSettingsRepository(); + await tester.pumpWidget( + MaterialApp( + home: OnboardingScreen( + networkRepository: InMemoryNetworkRepository(const []), + onCompleted: () async {}, + permissions: perms, + settingsRepository: settings, + ), + ), + ); + await tester.pump(); + await _toNotificationsStep(tester); + + await tester.tap(find.byKey(const Key('onboarding-allow-notifications'))); + await tester.pumpAndSettle(); + + expect(perms.notifRequests, 1); + expect(settings.settings.notificationsEnabled, isTrue); + expect(find.text('Notifications enabled.'), findsOneWidget); + }); + + testWidgets('denying notifications in onboarding leaves the setting off', ( + tester, + ) async { + final perms = _FakePermissions(AppPermissionResult.denied); + final settings = _MemSettingsRepository(); + await tester.pumpWidget( + MaterialApp( + home: OnboardingScreen( + networkRepository: InMemoryNetworkRepository(const []), + onCompleted: () async {}, + permissions: perms, + settingsRepository: settings, + ), + ), + ); + await tester.pump(); + await _toNotificationsStep(tester); + + await tester.tap(find.byKey(const Key('onboarding-allow-notifications'))); + await tester.pumpAndSettle(); + + expect(perms.notifRequests, 1); + expect(settings.settings.notificationsEnabled, isFalse); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 349469b..6393e59 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -314,7 +314,12 @@ void main() { await tester.tap(find.text('Next')); await tester.pumpAndSettle(); - // Channels step -> Finish. + // Channels step -> Next. + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + + // Notifications step -> Finish (permission prompt skipped). + expect(find.text('Notifications'), findsWidgets); await tester.tap(find.text('Finish')); await tester.pumpAndSettle(); From 0af1514fe5c9b780aae85c768b52a6421e9b27a0 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 15:04:03 +0200 Subject: [PATCH 4/5] feat: integrate Firebase Analytics, Crashlytics and App Check Add Firebase (core/analytics/crashlytics/app_check) with App Check Play Integrity (debug provider in debug). Analytics and Crashlytics collection are off by default and gated behind an analyticsConsent setting; a consent toggle lives in Settings and onboarding, and the app applies it on load. Uncaught errors are chained into Crashlytics alongside the existing email crash reporter. Update privacy copy to describe the opt-in telemetry. --- .gitignore | 3 + android/app/build.gradle.kts | 2 + android/settings.gradle.kts | 2 + lib/app/app.dart | 20 ++-- lib/core/firebase/firebase_service.dart | 95 ++++++++++++++++++ lib/core/models/app_settings.dart | 9 ++ .../presentation/data_privacy_screen.dart | 10 +- .../presentation/onboarding_screen.dart | 29 +++++- .../presentation/settings_screen.dart | 16 +++- lib/main.dart | 14 ++- macos/Flutter/GeneratedPluginRegistrant.swift | 8 ++ pubspec.lock | 96 +++++++++++++++++++ pubspec.yaml | 4 + ...notification_permission_settings_test.dart | 27 ++++++ test/onboarding_permission_test.dart | 41 +++++++- test/widget_test.dart | 2 +- .../flutter/generated_plugin_registrant.cc | 6 ++ windows/flutter/generated_plugins.cmake | 2 + 18 files changed, 366 insertions(+), 20 deletions(-) create mode 100644 lib/core/firebase/firebase_service.dart diff --git a/.gitignore b/.gitignore index c2a5df5..fbecefe 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ app.*.map.json /secrets/ /android/build/reports/problems/problems-report.html /android/.kotlin/ + +# Firebase config (kept out of the repo) +/android/app/google-services.json diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8a75d2b..7ba6a4c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -7,6 +7,8 @@ plugins { id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") + id("com.google.gms.google-services") + id("com.google.firebase.crashlytics") } val secretsPropertiesFile = rootProject.file("../secrets/gradle.properties") diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index c21f0c5..c696f62 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -21,6 +21,8 @@ plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "9.0.1" apply false id("org.jetbrains.kotlin.android") version "2.3.20" apply false + id("com.google.gms.google-services") version "4.4.2" apply false + id("com.google.firebase.crashlytics") version "3.0.3" apply false } include(":app") diff --git a/lib/app/app.dart b/lib/app/app.dart index e9f6011..0c3f87f 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,6 +1,7 @@ import 'package:androidircx/app/theme/app_theme.dart'; import 'dart:async'; +import 'package:androidircx/core/firebase/firebase_service.dart'; import 'package:androidircx/core/platform/foreground_connection_service.dart'; import 'package:androidircx/core/platform/screen_security.dart'; import 'package:androidircx/core/security/secret_storage.dart'; @@ -35,6 +36,7 @@ class AndroidIrcxApp extends StatefulWidget { class _AndroidIrcxAppState extends State { late final AppSettingsController _settingsController; bool? _appliedScreenSecure; + bool? _appliedAnalyticsConsent; @override void initState() { @@ -42,21 +44,25 @@ class _AndroidIrcxAppState extends State { _settingsController = AppSettingsController( repository: widget.settingsRepository, ); - _settingsController.addListener(_applyScreenSecurity); + _settingsController.addListener(_applySettingsSideEffects); _settingsController.load(); } - void _applyScreenSecurity() { - final secure = _settingsController.settings.screenshotProtection; - if (secure != _appliedScreenSecure) { - _appliedScreenSecure = secure; - unawaited(const ScreenSecurity().setSecure(secure)); + void _applySettingsSideEffects() { + final settings = _settingsController.settings; + if (settings.screenshotProtection != _appliedScreenSecure) { + _appliedScreenSecure = settings.screenshotProtection; + unawaited(const ScreenSecurity().setSecure(settings.screenshotProtection)); + } + if (settings.analyticsConsent != _appliedAnalyticsConsent) { + _appliedAnalyticsConsent = settings.analyticsConsent; + unawaited(FirebaseService.instance.setConsent(settings.analyticsConsent)); } } @override void dispose() { - _settingsController.removeListener(_applyScreenSecurity); + _settingsController.removeListener(_applySettingsSideEffects); _settingsController.dispose(); super.dispose(); } diff --git a/lib/core/firebase/firebase_service.dart b/lib/core/firebase/firebase_service.dart new file mode 100644 index 0000000..98b496b --- /dev/null +++ b/lib/core/firebase/firebase_service.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:firebase_app_check/firebase_app_check.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:flutter/foundation.dart'; + +/// Owns the Firebase integration: App Check (Play Integrity, anti-abuse and +/// always on), plus Analytics and Crashlytics whose data collection stays OFF +/// until the user consents. Uncaught Flutter/platform errors are chained into +/// Crashlytics on top of any existing handlers (e.g. the email crash reporter). +class FirebaseService { + FirebaseService(); + + /// App-wide instance used by `main`, the settings consent toggle and the + /// onboarding step. + static final FirebaseService instance = FirebaseService(); + + bool _initialized = false; + bool _consent = false; + + bool get isInitialized => _initialized; + FirebaseAnalytics? get analytics => + _initialized ? FirebaseAnalytics.instance : null; + + /// Initializes Firebase and App Check, and routes uncaught errors into + /// Crashlytics. Safe to call once; never throws to the caller. + Future initialize() async { + if (_initialized) { + return; + } + await Firebase.initializeApp(); + await FirebaseAppCheck.instance.activate( + providerAndroid: + kReleaseMode ? AndroidPlayIntegrityProvider() : AndroidDebugProvider(), + ); + _initialized = true; + + // Collection stays off until the user consents; apply the default now. + await _applyConsent(_consent); + + final priorFlutterHandler = FlutterError.onError; + FlutterError.onError = (details) { + priorFlutterHandler?.call(details); + // No-op unless Crashlytics collection is enabled (consent given). + unawaited( + FirebaseCrashlytics.instance.recordFlutterError(details, fatal: true), + ); + }; + + final priorPlatformHandler = PlatformDispatcher.instance.onError; + PlatformDispatcher.instance.onError = (error, stack) { + unawaited( + FirebaseCrashlytics.instance.recordError(error, stack, fatal: true), + ); + return priorPlatformHandler?.call(error, stack) ?? false; + }; + } + + /// Enables/disables Analytics and Crashlytics collection to match consent. + Future setConsent(bool consent) async { + _consent = consent; + await _applyConsent(consent); + } + + Future _applyConsent(bool consent) async { + if (!_initialized) { + return; + } + try { + await FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(consent); + await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled( + consent, + ); + } catch (_) { + // Best effort; never let telemetry toggling crash the app. + } + } + + /// Logs a named analytics event (no-op unless initialized + consented). + Future logEvent(String name, {Map? parameters}) async { + if (!_initialized || !_consent) { + return; + } + try { + await FirebaseAnalytics.instance.logEvent( + name: name, + parameters: parameters, + ); + } catch (_) { + // Ignore analytics failures. + } + } +} diff --git a/lib/core/models/app_settings.dart b/lib/core/models/app_settings.dart index f698971..6cff832 100644 --- a/lib/core/models/app_settings.dart +++ b/lib/core/models/app_settings.dart @@ -22,6 +22,7 @@ class AppSettings { this.nickColorMode = NickColorMode.soft, this.onboardingCompleted = false, this.appLockEnabled = false, + this.analyticsConsent = false, this.notificationsEnabled = false, this.notifyHighlights = true, this.notifyPrivateMessages = true, @@ -61,6 +62,10 @@ class AppSettings { final bool appLockEnabled; // Notifications. + /// Whether the user consented to Firebase Analytics + Crashlytics data + /// collection. Off by default; collection stays disabled until this is true. + final bool analyticsConsent; + /// Master switch for notifications. Stays false until the OS notification /// permission (POST_NOTIFICATIONS) is granted; the per-type toggles below /// only take effect while this is true. @@ -107,6 +112,7 @@ class AppSettings { NickColorMode? nickColorMode, bool? onboardingCompleted, bool? appLockEnabled, + bool? analyticsConsent, bool? notificationsEnabled, bool? notifyHighlights, bool? notifyPrivateMessages, @@ -146,6 +152,7 @@ class AppSettings { nickColorMode: nickColorMode ?? this.nickColorMode, onboardingCompleted: onboardingCompleted ?? this.onboardingCompleted, appLockEnabled: appLockEnabled ?? this.appLockEnabled, + analyticsConsent: analyticsConsent ?? this.analyticsConsent, notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled, notifyHighlights: notifyHighlights ?? this.notifyHighlights, notifyPrivateMessages: @@ -184,6 +191,7 @@ class AppSettings { 'nickColorMode': nickColorMode.name, 'onboardingCompleted': onboardingCompleted, 'appLockEnabled': appLockEnabled, + 'analyticsConsent': analyticsConsent, 'notificationsEnabled': notificationsEnabled, 'notifyHighlights': notifyHighlights, 'notifyPrivateMessages': notifyPrivateMessages, @@ -240,6 +248,7 @@ class AppSettings { ), onboardingCompleted: (json['onboardingCompleted'] as bool?) ?? false, appLockEnabled: (json['appLockEnabled'] as bool?) ?? false, + analyticsConsent: (json['analyticsConsent'] as bool?) ?? false, notificationsEnabled: (json['notificationsEnabled'] as bool?) ?? false, notifyHighlights: (json['notifyHighlights'] as bool?) ?? true, notifyPrivateMessages: (json['notifyPrivateMessages'] as bool?) ?? true, diff --git a/lib/features/onboarding/presentation/data_privacy_screen.dart b/lib/features/onboarding/presentation/data_privacy_screen.dart index 69ffc8a..1800237 100644 --- a/lib/features/onboarding/presentation/data_privacy_screen.dart +++ b/lib/features/onboarding/presentation/data_privacy_screen.dart @@ -47,9 +47,13 @@ class DataPrivacyScreen extends StatelessWidget { 'protocol; use TLS and SASL for privacy in transit.', ), const _PrivacyPoint( - icon: Icons.cloud_off_outlined, - title: 'No analytics or ads', - body: 'This build contains no advertising or analytics SDKs.', + icon: Icons.insights_outlined, + title: 'Optional analytics & crash reports', + body: + 'No ads at the moment 🙂. Anonymous usage analytics and crash ' + 'reports (Firebase Analytics/Crashlytics) are OFF by default ' + 'and only collected if you opt in; you can change this any ' + 'time in Settings.', ), const SizedBox(height: 16), FilledButton.icon( diff --git a/lib/features/onboarding/presentation/onboarding_screen.dart b/lib/features/onboarding/presentation/onboarding_screen.dart index ebfa264..612e0de 100644 --- a/lib/features/onboarding/presentation/onboarding_screen.dart +++ b/lib/features/onboarding/presentation/onboarding_screen.dart @@ -38,6 +38,7 @@ enum _NetworkMode { dbase, custom, later } class _OnboardingScreenState extends State { int _step = 0; bool _consentAccepted = false; + bool _shareAnalytics = false; bool _saving = false; bool _notificationsAsked = false; bool _notificationsGranted = false; @@ -115,6 +116,16 @@ class _OnboardingScreenState extends State { if (_networkMode != _NetworkMode.later) { await widget.networkRepository.saveNetwork(_buildNetwork()); } + if (_shareAnalytics) { + try { + final settings = await _settingsRepository.loadSettings(); + await _settingsRepository.saveSettings( + settings.copyWith(analyticsConsent: true), + ); + } catch (_) { + // Best effort; the user can still opt in from Settings. + } + } await widget.onCompleted(); } @@ -359,9 +370,11 @@ class _OnboardingScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - 'AndroidIRCX stores your data on this device only — no account, no ' - 'cloud sync, no ads or analytics. History is encrypted behind your ' - 'fingerprint/PIN and secrets live in secure storage.', + 'AndroidIRCX keeps your chat data on this device — no account and no ' + 'cloud sync of messages. History is encrypted behind your ' + 'fingerprint/PIN and secrets live in secure storage. Anonymous usage ' + 'analytics and crash reports are optional and stay off unless you ' + 'turn them on below.', ), const SizedBox(height: 12), OutlinedButton.icon( @@ -383,6 +396,16 @@ class _OnboardingScreenState extends State { 'I have read and accept the privacy policy and terms.', ), ), + CheckboxListTile( + key: const Key('onboarding-analytics-consent'), + contentPadding: EdgeInsets.zero, + value: _shareAnalytics, + onChanged: (value) => + setState(() => _shareAnalytics = value ?? false), + title: const Text( + 'Share anonymous usage & crash data to improve the app (optional).', + ), + ), ], ); } diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 8b68e3a..62c5c52 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -560,6 +560,20 @@ class _SettingsScreenState extends State { : const Text('Grant'), onTap: _cameraGranted ? null : _requestCameraPermission, ), + const Divider(height: 1), + SwitchListTile( + key: const Key('settings-analytics-consent'), + secondary: const Icon(Icons.insights_outlined), + title: const Text('Share anonymous usage & crash data'), + subtitle: const Text( + 'Send anonymized analytics and crash reports (Firebase) ' + 'to help improve the app. Off by default.', + ), + value: _settings.analyticsConsent, + onChanged: (value) => _saveSettings( + _settings.copyWith(analyticsConsent: value), + ), + ), ], ), const SizedBox(height: 12), @@ -1137,7 +1151,7 @@ Network passwords, SASL passwords, proxy passwords, and auto-join channel keys a IRC messages are sent to the networks you connect to. DCC transfers connect directly to the peer or through reverse/passive negotiation when available. -The app does not include ads, analytics, crash reporting, WebRTC calls, scripting, or E2EE in the current release slice. +No ads at the moment :). Anonymous usage analytics and crash reports (Firebase Analytics/Crashlytics) are off by default and only collected if you opt in under Permissions; you can turn them off any time. '''; class _SettingsSection extends StatelessWidget { diff --git a/lib/main.dart b/lib/main.dart index 3e86d59..ea444e4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,12 +1,18 @@ import 'package:androidircx/app/app.dart'; import 'package:androidircx/core/diagnostics/crash_reporter.dart'; +import 'package:androidircx/core/firebase/firebase_service.dart'; import 'package:flutter/widgets.dart'; -void main() { +Future main() async { WidgetsFlutterBinding.ensureInitialized(); - // Capture uncaught framework/platform errors into on-device crash reports. - // Nothing is sent anywhere automatically; the user emails a report manually - // from Settings if they choose. + // On-device email crash reporter (always available, no analytics). CrashReporter().install(); + // Firebase App Check runs immediately (anti-abuse); Analytics/Crashlytics + // collection stays off until the user consents. Optional — never blocks boot. + try { + await FirebaseService.instance.initialize(); + } catch (_) { + // Continue without Firebase if initialization fails. + } runApp(const AndroidIrcxApp()); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 15575c2..607c9c3 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,10 @@ import FlutterMacOS import Foundation import file_selector_macos +import firebase_analytics +import firebase_app_check +import firebase_core +import firebase_crashlytics import flutter_secure_storage_darwin import in_app_review import local_auth_darwin @@ -15,6 +19,10 @@ import video_player_avfoundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) + FirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FirebaseAppCheckPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) diff --git a/pubspec.lock b/pubspec.lock index a6b88fc..9cb1200 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "103.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: "6727cf2ced9b104abca9daa278380be2eca2b98ce33d4b46f11708e387dc6b4d" + url: "https://pub.dev" + source: hosted + version: "1.3.76" analyzer: dependency: transitive description: @@ -281,6 +289,94 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+5" + firebase_analytics: + dependency: "direct main" + description: + name: firebase_analytics + sha256: a139dd0ada1c6e0ffd77bfdb877ac9061ffdec7c415e3e06113bdf8844db771f + url: "https://pub.dev" + source: hosted + version: "12.4.6" + firebase_analytics_platform_interface: + dependency: transitive + description: + name: firebase_analytics_platform_interface + sha256: "448c319ea895da002e43892c7d3f15dccbc3c4c3b81d3e307e37da885ea52bdf" + url: "https://pub.dev" + source: hosted + version: "6.0.6" + firebase_analytics_web: + dependency: transitive + description: + name: firebase_analytics_web + sha256: "67f287a0df75f27eafdd4a66a41e44f232c3aba713ea4794a1159893665a8bf5" + url: "https://pub.dev" + source: hosted + version: "0.6.1+12" + firebase_app_check: + dependency: "direct main" + description: + name: firebase_app_check + sha256: d422642d973b0c636e0582127a47319d8ac9140195e7330c71965d3b6b263095 + url: "https://pub.dev" + source: hosted + version: "0.4.6" + firebase_app_check_platform_interface: + dependency: transitive + description: + name: firebase_app_check_platform_interface + sha256: "645ff25c18160a2c6e6b487d3466b247619e0bf09e2b1f641d476b2a72f92106" + url: "https://pub.dev" + source: hosted + version: "0.4.2" + firebase_app_check_web: + dependency: transitive + description: + name: firebase_app_check_web + sha256: "66c938d522c8c325515d222aa55560921b063d06791cf95aa7c405183a3a454f" + url: "https://pub.dev" + source: hosted + version: "0.2.6" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "9478ca6700c02d315c6aba37e206e612317f98bb4f335bb1cbd6e0ce67dcf764" + url: "https://pub.dev" + source: hosted + version: "4.13.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: e28f9afdcb5b0f0a8ea74ea3b322f5a7592c81cb45dd9d189913bdae08a2089a + url: "https://pub.dev" + source: hosted + version: "8.1.0" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: f471a288b0101a45567548322ac4a5ad31e3ecbf87a576dcc0e424e6a11e04ea + url: "https://pub.dev" + source: hosted + version: "3.10.0" + firebase_crashlytics: + dependency: "direct main" + description: + name: firebase_crashlytics + sha256: "96c1d85de9eddc07061d3f7e2fb75596e75a45c9cec9c2e3a7cc9f97e6f3a748" + url: "https://pub.dev" + source: hosted + version: "5.2.7" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + sha256: "47d83b71fd39c580297c696a507a7c2652680ea2045e40c6c4e3c371b0ee7bc6" + url: "https://pub.dev" + source: hosted + version: "3.8.27" fixnum: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d710acb..06b2425 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,10 @@ dependencies: in_app_review: ^2.0.12 video_player: ^2.9.2 permission_handler: ^11.3.1 + firebase_core: ^4.13.0 + firebase_analytics: ^12.4.6 + firebase_crashlytics: ^5.2.7 + firebase_app_check: ^0.4.6 dev_dependencies: flutter_test: diff --git a/test/notification_permission_settings_test.dart b/test/notification_permission_settings_test.dart index ecc00a3..340e5db 100644 --- a/test/notification_permission_settings_test.dart +++ b/test/notification_permission_settings_test.dart @@ -111,6 +111,33 @@ void main() { expect(saved.notificationsEnabled, isFalse); }); + testWidgets('analytics consent toggle saves the setting', (tester) async { + final perms = _FakePermissions(); + await tester.pumpWidget( + MaterialApp(home: SettingsScreen(permissions: perms)), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.scrollUntilVisible( + find.byKey(const Key('settings-analytics-consent')), + 250, + scrollable: find.byType(Scrollable).first, + ); + await tester.ensureVisible( + find.byKey(const Key('settings-analytics-consent')), + ); + await tester.pumpAndSettle(); + + var saved = await SharedPrefsSettingsRepository().loadSettings(); + expect(saved.analyticsConsent, isFalse); + + await tester.tap(find.byKey(const Key('settings-analytics-consent'))); + await tester.pumpAndSettle(); + + saved = await SharedPrefsSettingsRepository().loadSettings(); + expect(saved.analyticsConsent, isTrue); + }); + testWidgets('granting camera permission shows granted state', (tester) async { final perms = _FakePermissions(camResult: AppPermissionResult.granted); await tester.pumpWidget( diff --git a/test/onboarding_permission_test.dart b/test/onboarding_permission_test.dart index 89a065b..fc9981b 100644 --- a/test/onboarding_permission_test.dart +++ b/test/onboarding_permission_test.dart @@ -39,7 +39,7 @@ class _MemSettingsRepository implements SettingsRepository { Future _toNotificationsStep(WidgetTester tester) async { await tester.tap(find.text('Next')); // welcome await tester.pumpAndSettle(); - await tester.tap(find.byType(Checkbox)); // privacy consent + await tester.tap(find.byType(Checkbox).first); // privacy consent (terms) await tester.pumpAndSettle(); await tester.tap(find.text('Next')); // privacy await tester.pumpAndSettle(); @@ -78,6 +78,45 @@ void main() { expect(find.text('Notifications enabled.'), findsOneWidget); }); + testWidgets('opting into analytics in onboarding saves consent', ( + tester, + ) async { + final settings = _MemSettingsRepository(); + await tester.pumpWidget( + MaterialApp( + home: OnboardingScreen( + networkRepository: InMemoryNetworkRepository(const []), + onCompleted: () async {}, + permissions: _FakePermissions(AppPermissionResult.granted), + settingsRepository: settings, + ), + ), + ); + await tester.pump(); + + // Welcome -> Privacy. + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + // Accept terms + opt into analytics. + await tester.tap(find.byType(Checkbox).first); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('onboarding-analytics-consent'))); + await tester.pumpAndSettle(); + // Advance to the end and finish. + await tester.tap(find.text('Next')); // privacy + await tester.pumpAndSettle(); + await tester.tap(find.text('Next')); // identity + await tester.pumpAndSettle(); + await tester.tap(find.text('Next')); // network + await tester.pumpAndSettle(); + await tester.tap(find.text('Next')); // channels -> notifications + await tester.pumpAndSettle(); + await tester.tap(find.text('Finish')); + await tester.pumpAndSettle(); + + expect(settings.settings.analyticsConsent, isTrue); + }); + testWidgets('denying notifications in onboarding leaves the setting off', ( tester, ) async { diff --git a/test/widget_test.dart b/test/widget_test.dart index 6393e59..60674dc 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -301,7 +301,7 @@ void main() { await tester.pumpAndSettle(); // Privacy step: consent required before Next is enabled. - await tester.tap(find.byType(Checkbox)); + await tester.tap(find.byType(Checkbox).first); await tester.pumpAndSettle(); await tester.tap(find.text('Next')); await tester.pumpAndSettle(); diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 4ae873f..30d4ad0 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,8 @@ #include "generated_plugin_registrant.h" #include +#include +#include #include #include #include @@ -15,6 +17,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FirebaseAppCheckPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseAppCheckPluginCApi")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); LocalAuthPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 5def12b..030c229 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,8 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows + firebase_app_check + firebase_core flutter_secure_storage_windows local_auth_windows permission_handler_windows From 2c50171661f0c5d9adbc68029f01b8571915d643 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 15:24:29 +0200 Subject: [PATCH 5/5] chore: bump version to 1.0.4+6 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 06b2425..35dc346 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.3+5 +version: 1.0.4+6 environment: sdk: ^3.11.1