From 738b62666abc4b3e8b12e81f91adf260762149e8 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 13:54:52 +0200 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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 From 47a0c848b7f0eacc8d6dc187d63a78fe07c1feb7 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 21 Aug 2026 15:31:01 +0200 Subject: [PATCH 6/7] fix: mark camera hardware features optional; bump to 1.0.4+7 Adding the CAMERA runtime permission made Google Play imply a required camera feature and drop ~418 camera-less devices (Android Auto, some TVs/tablets). Declare camera/autofocus/any as not required since capture is optional. --- android/app/src/main/AndroidManifest.xml | 9 +++++++++ pubspec.yaml | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 51af3d4..82850b4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,15 @@ + + + + + Date: Tue, 25 Aug 2026 08:41:04 +0200 Subject: [PATCH 7/7] fix: keep sessions alive behind app lock Preserve mounted session state while locked, reuse recent local auth, dedupe channel tabs by IRC casefolding, and bump to 1.0.5+8. --- CONTRIBUTING.md | 6 +- README.md | 37 +-- lib/core/security/device_unlock_session.dart | 74 +++++ .../security/local_auth_history_unlock.dart | 42 ++- .../application/chat_session_controller.dart | 298 ++++++++++++++---- .../security/presentation/app_lock_gate.dart | 71 +++-- pubspec.yaml | 2 +- test/app_lock_gate_test.dart | 51 +++ test/chat_session_controller_test.dart | 215 +++++++------ test/device_unlock_session_test.dart | 61 ++++ 10 files changed, 647 insertions(+), 210 deletions(-) create mode 100644 lib/core/security/device_unlock_session.dart create mode 100644 test/device_unlock_session_test.dart diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2bb997b..3b37123 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,8 @@ Thanks for your interest in contributing to AndroidIRCX. ## Code Style -- Use TypeScript for new code. -- Prefer small, focused services and components. +- Use Dart and Flutter for new code. +- Prefer small, focused services, controllers, repositories, and widgets. - Keep changes scoped and avoid unrelated refactors. ## Reporting Bugs @@ -32,4 +32,4 @@ Please include: ## License -By contributing, you agree that your contributions will be licensed under the GPL-3.0. \ No newline at end of file +By contributing, you agree that your contributions will be licensed under the GPL-3.0. diff --git a/README.md b/README.md index f09f4e8..243f79c 100644 --- a/README.md +++ b/README.md @@ -20,39 +20,32 @@ existing React Native application. ## Current Status -This repository is an active rewrite in progress. +This repository is an active Flutter rewrite in release hardening. Implemented in the current Flutter app: -- network list and add/edit flow -- basic IRC socket connection lifecycle -- server, channel, and query tabs -- basic message sending and receiving -- local persistence for networks, tabs, message history, and a small settings set -- reconnect banner and basic connection status UX +- network list, server directory, add/edit flow, identity profiles, and in-chat network switching +- TCP/TLS IRC transport, IRCv3 WebSocket transport, reconnect/backoff, and foreground-service integration +- IRC parser, ISUPPORT, numerics, CAP 302, SASL PLAIN/SCRAM-SHA-256/EXTERNAL, CTCP, and IRCv3 message features +- server, channel, query, notice, and DCC tabs with command suggestions and rich channel user actions +- encrypted local message history with retention/search/export support +- DCC CHAT/SEND, safe file picking, transfer progress, media cards, previews, downloads, camera/gallery capture, and in-app audio/video playback +- onboarding, consent/privacy screens, app lock, notification/display/writing settings, themes, backup/restore, ignore lists, auto-away/highlights/sounds, auto-rejoin, diagnostics, and crash reporting -Not implemented yet in full parity: +Still outside the default-client release scope: -- CAP/SASL full flow -- multi-network orchestration parity -- advanced numerics and command coverage -- notifications/background behavior -- encryption -- DCC/media -- monetization and security platform features - -The feature list below describes the target AndroidIRCx parity scope, not the current Flutter -implementation status. +- E2EE, ads/IAP, and scripting are later product/security decisions +- WebRTC calling is not planned +- upload/share endpoints are deferred until a concrete product endpoint exists ## 🔐 Security - **TLS/SSL** -- full encrypted connection support - **SASL** -- PLAIN, SCRAM-SHA-256, EXTERNAL (client certificates) -- **E2E Encryption** -- libsodium XChaCha20-Poly1305 with context-bound AAD -- **Secure Storage** -- device Keychain for secrets (AsyncStorage fallback with warning) +- **Encrypted History** -- local message bodies encrypted with AES-256-GCM +- **Secure Storage** -- platform-backed storage for server passwords, SASL secrets, channel keys, and certificates - **App Lock** -- PIN and biometric with auto-lock on background/launch -- **Kill Switch** -- emergency disconnect and optional data wipe -- **Play Integrity** -- Google Play Integrity verification +- **Crash Reports** -- sanitized on-device reports with secret redaction ## 🤝 Contributing diff --git a/lib/core/security/device_unlock_session.dart b/lib/core/security/device_unlock_session.dart new file mode 100644 index 0000000..678ff95 --- /dev/null +++ b/lib/core/security/device_unlock_session.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:local_auth/local_auth.dart'; + +typedef DeviceUnlockPrompt = Future Function(String reason); + +/// Coalesces biometric/PIN prompts and allows a short reuse window after a +/// successful device unlock. +class DeviceUnlockSession { + DeviceUnlockSession({ + DeviceUnlockPrompt? prompt, + Duration reuseWindow = const Duration(seconds: 30), + DateTime Function()? now, + }) : _prompt = prompt ?? _defaultPrompt, + _reuseWindow = reuseWindow, + _now = now ?? DateTime.now; + + static final DeviceUnlockSession instance = DeviceUnlockSession(); + + final DeviceUnlockPrompt _prompt; + final Duration _reuseWindow; + final DateTime Function() _now; + DateTime? _lastSuccessAt; + Future? _inFlight; + + Future authenticate({required String reason}) { + final lastSuccessAt = _lastSuccessAt; + final now = _now(); + if (lastSuccessAt != null && + now.difference(lastSuccessAt).abs() <= _reuseWindow) { + return Future.value(true); + } + + final existing = _inFlight; + if (existing != null) { + return existing; + } + + final next = _prompt(reason).then((unlocked) { + if (unlocked) { + _lastSuccessAt = _now(); + } + return unlocked; + }); + _inFlight = next; + return next.whenComplete(() { + if (identical(_inFlight, next)) { + _inFlight = null; + } + }); + } + + void invalidate() { + _lastSuccessAt = null; + } + + static Future _defaultPrompt(String reason) async { + try { + final auth = LocalAuthentication(); + final supported = + await auth.isDeviceSupported() || await auth.canCheckBiometrics; + if (!supported) { + return false; + } + return await auth.authenticate( + localizedReason: reason, + biometricOnly: false, + persistAcrossBackgrounding: true, + ); + } catch (_) { + return false; + } + } +} diff --git a/lib/core/security/local_auth_history_unlock.dart b/lib/core/security/local_auth_history_unlock.dart index a20b20a..d341c46 100644 --- a/lib/core/security/local_auth_history_unlock.dart +++ b/lib/core/security/local_auth_history_unlock.dart @@ -1,30 +1,40 @@ import 'package:androidircx/core/security/history_encryption_key_manager.dart'; +import 'package:androidircx/core/security/device_unlock_session.dart'; import 'package:local_auth/local_auth.dart'; /// Biometric (+ device PIN/passphrase fallback) gate for the encrypted history /// key, backed by `local_auth`. -class LocalAuthHistoryUnlockAuthenticator implements HistoryUnlockAuthenticator { +class LocalAuthHistoryUnlockAuthenticator + implements HistoryUnlockAuthenticator { LocalAuthHistoryUnlockAuthenticator([LocalAuthentication? auth]) - : _auth = auth ?? LocalAuthentication(); + : _unlockSession = auth == null + ? DeviceUnlockSession.instance + : DeviceUnlockSession(prompt: _promptFor(auth)); - final LocalAuthentication _auth; + final DeviceUnlockSession _unlockSession; @override Future authenticate({required String reason}) async { - try { - final supported = - await _auth.isDeviceSupported() || await _auth.canCheckBiometrics; - if (!supported) { + return _unlockSession.authenticate(reason: reason); + } + + static DeviceUnlockPrompt _promptFor(LocalAuthentication auth) { + return (reason) async { + try { + final supported = + await auth.isDeviceSupported() || await auth.canCheckBiometrics; + if (!supported) { + return false; + } + return await auth.authenticate( + localizedReason: reason, + // biometricOnly: false allows the device PIN/passphrase fallback. + biometricOnly: false, + persistAcrossBackgrounding: true, + ); + } catch (_) { return false; } - return await _auth.authenticate( - localizedReason: reason, - // biometricOnly: false allows the device PIN/passphrase fallback. - biometricOnly: false, - persistAcrossBackgrounding: true, - ); - } catch (_) { - return false; - } + }; } } diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index e75a1f5..9d3b1ad 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -736,8 +736,9 @@ class ChatSessionController extends ChangeNotifier { if (repository != null) { _autoModeEntries = await repository.remove(entry); } else { - _autoModeEntries = - _autoModeEntries.where((e) => e.key != entry.key).toList(); + _autoModeEntries = _autoModeEntries + .where((e) => e.key != entry.key) + .toList(); } notifyListeners(); } @@ -758,7 +759,8 @@ class ChatSessionController extends ChangeNotifier { final ownModes = _channelUserModes[tabId]?[currentNick.trim().toLowerCase()] ?? const {}; - final hasOp = ownModes.contains('o') || + final hasOp = + ownModes.contains('o') || ownModes.contains('q') || ownModes.contains('a'); final hasHalfOp = ownModes.contains('h'); @@ -786,9 +788,7 @@ class ChatSessionController extends ChangeNotifier { UserListType.autoVoice => hasOp || hasHalfOp, }; if (canApply) { - unawaited( - _ircService.sendRaw('MODE $channel +${type.modeChar} $nick'), - ); + unawaited(_ircService.sendRaw('MODE $channel +${type.modeChar} $nick')); return; } } @@ -2671,10 +2671,18 @@ class ChatSessionController extends ChangeNotifier { await _ircService.disconnect(rest.isEmpty ? null : rest); return; case 'autovoice': - await _handleAutoModeCommand(UserListType.autoVoice, rest, remove: false); + await _handleAutoModeCommand( + UserListType.autoVoice, + rest, + remove: false, + ); return; case 'unautovoice': - await _handleAutoModeCommand(UserListType.autoVoice, rest, remove: true); + await _handleAutoModeCommand( + UserListType.autoVoice, + rest, + remove: true, + ); return; case 'autoop': await _handleAutoModeCommand(UserListType.autoOp, rest, remove: false); @@ -2728,13 +2736,18 @@ class ChatSessionController extends ChangeNotifier { } final mask = tokens.first; final channels = tokens.length > 1 - ? tokens[1].split(',').map((c) => c.trim()).where((c) => c.isNotEmpty).toList() + ? tokens[1] + .split(',') + .map((c) => c.trim()) + .where((c) => c.isNotEmpty) + .toList() : const []; if (remove) { - final normalized = UserListEntry(type: type, mask: mask) - .normalizedMask - .toLowerCase(); + final normalized = UserListEntry( + type: type, + mask: mask, + ).normalizedMask.toLowerCase(); final matches = _autoModeEntries .where( (entry) => @@ -2812,8 +2825,9 @@ class ChatSessionController extends ChangeNotifier { kind: IrcMessageKind.system, ); } else { - final names = - _commandService.commands.map((command) => '/${command.name}').join(' '); + final names = _commandService.commands + .map((command) => '/${command.name}') + .join(' '); _appendMessage( tabId: activeTab.id, sender: '*', @@ -2851,7 +2865,9 @@ class ChatSessionController extends ChangeNotifier { _appendMessage( tabId: activeTab.id, sender: '*', - content: alreadyIgnored ? 'Already ignoring $mask' : 'Now ignoring $mask', + content: alreadyIgnored + ? 'Already ignoring $mask' + : 'Now ignoring $mask', kind: IrcMessageKind.system, ); } @@ -2911,8 +2927,9 @@ class ChatSessionController extends ChangeNotifier { return; } - final echoEnabled = - _ircService.enabledCapabilities.contains('echo-message'); + final echoEnabled = _ircService.enabledCapabilities.contains( + 'echo-message', + ); for (final tab in channelTabs) { if (asAction) { await _ircService.sendAction(target: tab.name, text: text); @@ -3014,8 +3031,9 @@ class ChatSessionController extends ChangeNotifier { } byHost.putIfAbsent(host, () => []).add(bare); } - final clones = byHost.entries.where((entry) => entry.value.length > 1).toList() - ..sort((a, b) => a.key.compareTo(b.key)); + final clones = + byHost.entries.where((entry) => entry.value.length > 1).toList() + ..sort((a, b) => a.key.compareTo(b.key)); if (clones.isEmpty) { _appendMessage( @@ -3062,18 +3080,18 @@ class ChatSessionController extends ChangeNotifier { return false; } for (final mask in _ignoreMasks) { - if (_matchesIgnoreMask(mask, prefix: frame.prefix, nick: frame.senderNick)) { + if (_matchesIgnoreMask( + mask, + prefix: frame.prefix, + nick: frame.senderNick, + )) { return true; } } return false; } - static bool _matchesIgnoreMask( - String mask, { - String? prefix, - String? nick, - }) { + static bool _matchesIgnoreMask(String mask, {String? prefix, String? nick}) { final hasHostMask = mask.contains('!') || mask.contains('@'); final target = hasHostMask ? (prefix ?? nick) : (nick ?? prefix); if (target == null || target.isEmpty) { @@ -3101,7 +3119,9 @@ class ChatSessionController extends ChangeNotifier { void _handleFilterCommand(String rest) { final trimmed = rest.trim(); // `-g` (global) is accepted for RN parity; filtering here is per-session. - final text = trimmed.startsWith('-g') ? trimmed.substring(2).trim() : trimmed; + final text = trimmed.startsWith('-g') + ? trimmed.substring(2).trim() + : trimmed; if (text.isEmpty) { _appendMessage( tabId: activeTab.id, @@ -3144,8 +3164,9 @@ class ChatSessionController extends ChangeNotifier { _appendMessage( tabId: activeTab.id, sender: '*', - content: - removed ? 'No longer filtering "$text"' : 'Not filtering "$text"', + content: removed + ? 'No longer filtering "$text"' + : 'Not filtering "$text"', kind: IrcMessageKind.system, ); } @@ -3252,7 +3273,10 @@ class ChatSessionController extends ChangeNotifier { final delayMs = int.tryParse(parts[1]); final repetitions = int.tryParse(parts[2]); final command = parts.skip(3).join(' '); - if (delayMs == null || delayMs <= 0 || repetitions == null || repetitions < 0) { + if (delayMs == null || + delayMs <= 0 || + repetitions == null || + repetitions < 0) { _appendMessage( tabId: activeTab.id, sender: 'error', @@ -3265,19 +3289,18 @@ class ChatSessionController extends ChangeNotifier { _commandTimers.remove(name)?.cancel(); var remaining = repetitions; - _commandTimers[name] = Timer.periodic( - Duration(milliseconds: delayMs), - (timer) { - unawaited(handleComposerSubmit(command)); - if (repetitions != 0) { - remaining--; - if (remaining <= 0) { - timer.cancel(); - _commandTimers.remove(name); - } + _commandTimers[name] = Timer.periodic(Duration(milliseconds: delayMs), ( + timer, + ) { + unawaited(handleComposerSubmit(command)); + if (repetitions != 0) { + remaining--; + if (remaining <= 0) { + timer.cancel(); + _commandTimers.remove(name); } - }, - ); + } + }); _appendMessage( tabId: activeTab.id, sender: '*', @@ -3933,9 +3956,9 @@ class ChatSessionController extends ChangeNotifier { kind: IrcMessageKind.event, ); case 'PONG': - final token = (frame.trailing ?? - (frame.params.isEmpty ? '' : frame.params.last)) - .trim(); + final token = + (frame.trailing ?? (frame.params.isEmpty ? '' : frame.params.last)) + .trim(); if (token.startsWith('LAG')) { final sentMs = int.tryParse(token.substring(3)); if (sentMs != null) { @@ -4368,6 +4391,7 @@ class ChatSessionController extends ChangeNotifier { _serverSupport = _serverSupport.mergeFrame(frame); _nickPrefixChars = _serverSupport.nickPrefixSymbols; _channelPrefixChars = _serverSupport.channelTypes; + _dedupeEquivalentTabs(); final tokens = isupportTokensFromFrame(frame); final supportText = tokens.join(' '); @@ -5123,14 +5147,17 @@ class ChatSessionController extends ChangeNotifier { } ChatTab _ensureChannelTab(String channel) { - final existing = _findTab(_channelTabId(network.id, channel)); + final normalizedChannel = channel.trim(); + final existing = + _findTab(_channelTabId(network.id, normalizedChannel)) ?? + _findEquivalentTab(ChatTabType.channel, normalizedChannel); if (existing != null) { return existing; } final tab = ChatTab( - id: _channelTabId(network.id, channel), - name: channel, + id: _channelTabId(network.id, normalizedChannel), + name: normalizedChannel, type: ChatTabType.channel, networkId: network.id, ); @@ -5142,14 +5169,17 @@ class ChatSessionController extends ChangeNotifier { } ChatTab _ensureQueryTab(String nick) { - final existing = _findTab(_queryTabId(network.id, nick)); + final normalizedNick = nick.trim(); + final existing = + _findTab(_queryTabId(network.id, normalizedNick)) ?? + _findEquivalentTab(ChatTabType.query, normalizedNick); if (existing != null) { return existing; } final tab = ChatTab( - id: _queryTabId(network.id, nick), - name: nick, + id: _queryTabId(network.id, normalizedNick), + name: normalizedNick, type: ChatTabType.query, networkId: network.id, ); @@ -5335,6 +5365,159 @@ class ChatSessionController extends ChangeNotifier { return null; } + ChatTab? _findEquivalentTab(ChatTabType type, String name) { + final key = _ircCasefold(name); + for (final tab in _tabs) { + if (tab.type == type && _ircCasefold(tab.name) == key) { + return tab; + } + } + return null; + } + + void _dedupeEquivalentTabs() { + final canonicalByKey = {}; + final aliases = {}; + final nextTabs = []; + + for (final tab in _tabs) { + if (tab.type != ChatTabType.channel && tab.type != ChatTabType.query) { + nextTabs.add(tab); + continue; + } + + final key = '${tab.type.name}:${_ircCasefold(tab.name)}'; + final canonical = canonicalByKey[key]; + if (canonical == null) { + canonicalByKey[key] = tab; + nextTabs.add(tab); + continue; + } + + aliases[tab.id] = canonical.id; + final canonicalIndex = nextTabs.indexWhere( + (item) => item.id == canonical.id, + ); + if (canonicalIndex != -1) { + nextTabs[canonicalIndex] = canonical.copyWith( + hasActivity: canonical.hasActivity || tab.hasActivity, + unreadCount: canonical.unreadCount + tab.unreadCount, + isEncrypted: canonical.isEncrypted || tab.isEncrypted, + ); + canonicalByKey[key] = nextTabs[canonicalIndex]; + } + } + + if (aliases.isEmpty) { + return; + } + + _tabs = nextTabs; + _activeTabId = aliases[_activeTabId] ?? _activeTabId; + _rekeyMessages(aliases); + _rekeySetMap(_channelUsers, aliases); + _rekeyStringMap(_channelTopics, aliases); + _rekeyStringMap(_channelModes, aliases); + _rekeyNestedSetMap(_channelUserModes, aliases); + _rekeyDateTimeMap(_readMarkers, aliases); + _rekeyNestedSetMap(_messageReactions, aliases); + _rekeySetMap(_typingUsersByTab, aliases); + } + + String _ircCasefold(String value) { + final lower = value.trim().toLowerCase(); + return switch (_serverSupport.caseMapping.toLowerCase()) { + 'ascii' => lower, + 'strict-rfc1459' => + lower.replaceAll('[', '{').replaceAll(']', '}').replaceAll(r'\', '|'), + _ => + lower + .replaceAll('[', '{') + .replaceAll(']', '}') + .replaceAll(r'\', '|') + .replaceAll('^', '~'), + }; + } + + void _rekeyMessages(Map aliases) { + final next = >{}; + for (final entry in _messages.entries) { + final tabId = aliases[entry.key] ?? entry.key; + final messages = next.putIfAbsent(tabId, () => []); + messages.addAll( + entry.value.map( + (message) => + message.tabId == tabId ? message : message.copyWith(tabId: tabId), + ), + ); + } + _messages + ..clear() + ..addAll(next); + } + + void _rekeySetMap(Map> map, Map aliases) { + final next = >{}; + for (final entry in map.entries) { + next + .putIfAbsent(aliases[entry.key] ?? entry.key, () => {}) + .addAll(entry.value); + } + map + ..clear() + ..addAll(next); + } + + void _rekeyStringMap(Map map, Map aliases) { + final next = {}; + for (final entry in map.entries) { + final tabId = aliases[entry.key] ?? entry.key; + final existing = next[tabId]; + next[tabId] = existing == null || existing.trim().isEmpty + ? entry.value + : existing; + } + map + ..clear() + ..addAll(next); + } + + void _rekeyNestedSetMap( + Map>> map, + Map aliases, + ) { + final next = >>{}; + for (final entry in map.entries) { + final tabId = aliases[entry.key] ?? entry.key; + final nested = next.putIfAbsent(tabId, () => >{}); + for (final nestedEntry in entry.value.entries) { + nested + .putIfAbsent(nestedEntry.key, () => {}) + .addAll(nestedEntry.value); + } + } + map + ..clear() + ..addAll(next); + } + + void _rekeyDateTimeMap( + Map map, + Map aliases, + ) { + final next = {}; + for (final entry in map.entries) { + final tabId = aliases[entry.key] ?? entry.key; + final existing = next[tabId]; + next[tabId] = existing == null || entry.value.isAfter(existing) + ? entry.value + : existing; + } + map + ..clear() + ..addAll(next); + } + IrcMessage? _appendMessage({ required String tabId, required String sender, @@ -5381,9 +5564,7 @@ class ChatSessionController extends ChangeNotifier { final appended = list.last; final repository = _historyRepository; if (repository != null) { - unawaited( - repository.append(networkId: network.id, message: appended), - ); + unawaited(repository.append(networkId: network.id, message: appended)); } return appended; } @@ -5636,6 +5817,7 @@ class ChatSessionController extends ChangeNotifier { _messages ..clear() ..addAll(snapshot.messagesByTab); + _dedupeEquivalentTabs(); for (final tab in _tabs) { _messages.putIfAbsent(tab.id, () => []); @@ -5703,8 +5885,9 @@ class ChatSessionController extends ChangeNotifier { } final current = _messages[tabId]; - final anchor = - (current == null || current.isEmpty) ? null : current.first.id; + final anchor = (current == null || current.isEmpty) + ? null + : current.first.id; final older = await repository.loadTabHistory( networkId: network.id, tabId: tabId, @@ -6498,7 +6681,8 @@ class ChatSessionController extends ChangeNotifier { String _serverTabId(String networkId) => 'server::$networkId'; String _noticeTabId(String networkId) => 'notice::$networkId'; String _channelTabId(String networkId, String name) => - 'channel::$networkId::$name'; -String _queryTabId(String networkId, String nick) => 'query::$networkId::$nick'; + 'channel::$networkId::${name.trim().toLowerCase()}'; +String _queryTabId(String networkId, String nick) => + 'query::$networkId::${nick.trim().toLowerCase()}'; String _dccTabId(String networkId, String sessionId) => 'dcc::$networkId::$sessionId'; diff --git a/lib/features/security/presentation/app_lock_gate.dart b/lib/features/security/presentation/app_lock_gate.dart index bcfa1a2..4d4fc68 100644 --- a/lib/features/security/presentation/app_lock_gate.dart +++ b/lib/features/security/presentation/app_lock_gate.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:local_auth/local_auth.dart'; +import 'package:androidircx/core/security/device_unlock_session.dart'; /// Returns true if the user successfully authenticated. typedef AppUnlockCallback = Future Function(); @@ -25,10 +25,10 @@ class AppLockGate extends StatefulWidget { State createState() => _AppLockGateState(); } -class _AppLockGateState extends State - with WidgetsBindingObserver { +class _AppLockGateState extends State with WidgetsBindingObserver { bool _unlocked = false; bool _prompting = false; + bool _hasMountedChild = false; @override void initState() { @@ -38,6 +38,7 @@ class _AppLockGateState extends State WidgetsBinding.instance.addPostFrameCallback((_) => _attemptUnlock()); } else { _unlocked = true; + _hasMountedChild = true; } } @@ -46,11 +47,13 @@ class _AppLockGateState extends State super.didUpdateWidget(oldWidget); if (!widget.enabled) { _unlocked = true; + _hasMountedChild = true; } else if (!oldWidget.enabled && widget.enabled) { // Just enabled at runtime: the user is already in the app (and confirmed // authentication in Settings), so stay unlocked now. The lock engages on // the next time the app is backgrounded and resumed. _unlocked = true; + _hasMountedChild = true; } } @@ -68,6 +71,7 @@ class _AppLockGateState extends State if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) { if (mounted) { + DeviceUnlockSession.instance.invalidate(); setState(() => _unlocked = false); } } else if (state == AppLifecycleState.resumed && !_unlocked) { @@ -76,21 +80,9 @@ class _AppLockGateState extends State } Future _defaultUnlock() async { - try { - final auth = LocalAuthentication(); - final supported = - await auth.isDeviceSupported() || await auth.canCheckBiometrics; - if (!supported) { - return false; - } - return await auth.authenticate( - localizedReason: 'Unlock AndroidIRCX', - biometricOnly: false, - persistAcrossBackgrounding: true, - ); - } catch (_) { - return false; - } + return DeviceUnlockSession.instance.authenticate( + reason: 'Unlock AndroidIRCX', + ); } Future _attemptUnlock() async { @@ -101,27 +93,60 @@ class _AppLockGateState extends State final unlocked = await (widget.unlock ?? _defaultUnlock)(); _prompting = false; if (mounted && unlocked) { - setState(() => _unlocked = true); + setState(() { + _unlocked = true; + _hasMountedChild = true; + }); } } @override Widget build(BuildContext context) { - if (!widget.enabled || _unlocked) { - return widget.child; + final locked = widget.enabled && !_unlocked; + if (!_hasMountedChild && locked) { + return const _AppLockScreen(); } + return Stack( + fit: StackFit.expand, + children: [ + ExcludeSemantics( + excluding: locked, + child: IgnorePointer( + ignoring: locked, + child: TickerMode(enabled: !locked, child: widget.child), + ), + ), + if (locked) const Positioned.fill(child: _AppLockScreen()), + ], + ); + } +} + +class _AppLockScreen extends StatelessWidget { + const _AppLockScreen(); + + @override + Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.lock_outline, size: 56, color: theme.colorScheme.primary), + Icon( + Icons.lock_outline, + size: 56, + color: theme.colorScheme.primary, + ), const SizedBox(height: 16), Text('AndroidIRCX is locked', style: theme.textTheme.titleMedium), const SizedBox(height: 16), FilledButton.icon( - onPressed: _attemptUnlock, + onPressed: () { + final state = context + .findAncestorStateOfType<_AppLockGateState>(); + state?._attemptUnlock(); + }, icon: const Icon(Icons.fingerprint), label: const Text('Unlock'), ), diff --git a/pubspec.yaml b/pubspec.yaml index 0ad288b..dc1eebb 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.4+7 +version: 1.0.5+8 environment: sdk: ^3.11.1 diff --git a/test/app_lock_gate_test.dart b/test/app_lock_gate_test.dart index 9faf8cb..ba065e4 100644 --- a/test/app_lock_gate_test.dart +++ b/test/app_lock_gate_test.dart @@ -37,6 +37,26 @@ class _HostState extends State<_Host> { } } +class _Probe extends StatefulWidget { + const _Probe({required this.onDisposed}); + + final VoidCallback onDisposed; + + @override + State<_Probe> createState() => _ProbeState(); +} + +class _ProbeState extends State<_Probe> { + @override + void dispose() { + widget.onDisposed(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => const Text('home content'); +} + void main() { testWidgets('enabling app lock at runtime does not immediately lock', ( tester, @@ -80,4 +100,35 @@ void main() { expect(find.text('AndroidIRCX is locked'), findsOneWidget); expect(find.text('home content'), findsNothing); }); + + testWidgets('re-locking keeps the child session tree mounted', ( + tester, + ) async { + var unlockCalls = 0; + var disposed = false; + await tester.pumpWidget( + MaterialApp( + home: AppLockGate( + enabled: true, + unlock: () async { + unlockCalls++; + return true; + }, + child: _Probe(onDisposed: () => disposed = true), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(unlockCalls, 1); + expect(find.text('home content'), findsOneWidget); + + final state = tester.state(find.byType(AppLockGate)) as dynamic; + state.didChangeAppLifecycleState(AppLifecycleState.paused); + await tester.pump(); + + expect(find.text('AndroidIRCX is locked'), findsOneWidget); + expect(disposed, isFalse); + expect(unlockCalls, 1); + }); } diff --git a/test/chat_session_controller_test.dart b/test/chat_session_controller_test.dart index 8a6ed1e..2a285fb 100644 --- a/test/chat_session_controller_test.dart +++ b/test/chat_session_controller_test.dart @@ -382,6 +382,36 @@ void main() { }); }); + test('joining the same channel with different case reuses one tab', () async { + const network = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + ); + final controller = ChatSessionController( + network: network, + ircService: IrcService(), + ); + + await controller.joinChannel( + const JoinChannelRequest(channel: '#AndroidIRCX'), + ); + await controller.joinChannel( + const JoinChannelRequest(channel: '#androidircx'), + ); + + final channelTabs = controller.tabs + .where((tab) => tab.type == ChatTabType.channel) + .toList(growable: false); + expect(channelTabs, hasLength(1)); + expect(channelTabs.single.name, '#AndroidIRCX'); + expect(controller.activeTabId, channelTabs.single.id); + + controller.dispose(); + }); + test( 'app lifecycle pause flushes message history without reconnecting', () async { @@ -2747,8 +2777,9 @@ void main() { transport.emit(':b!u@h PRIVMSG #two :hi'); await Future.delayed(Duration.zero); - final serverTab = - controller.tabs.firstWhere((tab) => tab.type == ChatTabType.server); + final serverTab = controller.tabs.firstWhere( + (tab) => tab.type == ChatTabType.server, + ); controller.selectTab(serverTab.id); final startId = controller.activeTabId; @@ -2779,8 +2810,9 @@ void main() { transport.emit(':server 001 AndroidIRCX :Welcome'); await Future.delayed(Duration.zero); await controller.measureLag(); - final pingLine = - transport.sentLines.lastWhere((line) => line.startsWith('PING :LAG')); + final pingLine = transport.sentLines.lastWhere( + (line) => line.startsWith('PING :LAG'), + ); final token = pingLine.substring('PING :'.length); transport.emit(':server PONG server :$token'); await Future.delayed(Duration.zero); @@ -2844,10 +2876,13 @@ void main() { transport.emit(':server 323 AndroidIRCX :End of /LIST'); await Future.delayed(Duration.zero); - expect(controller.channelListing.map((entry) => entry.name), - containsAll(['#dbase', '#flutter'])); - final dbase = - controller.channelListing.firstWhere((entry) => entry.name == '#dbase'); + expect( + controller.channelListing.map((entry) => entry.name), + containsAll(['#dbase', '#flutter']), + ); + final dbase = controller.channelListing.firstWhere( + (entry) => entry.name == '#dbase', + ); expect(dbase.userCount, 42); expect(dbase.topic, 'Main channel'); expect(controller.channelListInProgress, isFalse); @@ -2929,10 +2964,7 @@ void main() { isEmpty, ); // The message is still delivered to the query tab, just not notified. - expect( - controller.tabs.any((tab) => tab.name == 'alice'), - isTrue, - ); + expect(controller.tabs.any((tab) => tab.name == 'alice'), isTrue); await sub.cancel(); controller.dispose(); @@ -3444,9 +3476,7 @@ void main() { await controller.start(); Future emitCtcp(String body) async { - transport.emit( - ':alice!user@example PRIVMSG AndroidIRCX :$body', - ); + transport.emit(':alice!user@example PRIVMSG AndroidIRCX :$body'); await Future.delayed(Duration.zero); await Future.delayed(Duration.zero); } @@ -3460,10 +3490,7 @@ void main() { // Every CTCP reply must go out as a NOTICE (never PRIVMSG) so it cannot // trigger a reply loop, and PING must echo the requester's token back. - expect( - transport.sentLines, - contains('NOTICE alice :PING 999'), - ); + expect(transport.sentLines, contains('NOTICE alice :PING 999')); expect( transport.sentLines, contains( @@ -3488,8 +3515,7 @@ void main() { ); expect( transport.sentLines.any( - (line) => line.startsWith('NOTICE alice :TIME ') && - line.endsWith(''), + (line) => line.startsWith('NOTICE alice :TIME ') && line.endsWith(''), ), isTrue, ); @@ -3521,7 +3547,9 @@ void main() { await controller.handleComposerSubmit('/echo hello world'); expect( - controller.activeMessages.any((message) => message.content == 'hello world'), + controller.activeMessages.any( + (message) => message.content == 'hello world', + ), isTrue, ); // /echo must never hit the wire. @@ -3589,14 +3617,8 @@ void main() { expect(transport.sentLines, contains('PRIVMSG #room :hello all')); expect(transport.sentLines, contains('PRIVMSG #other :hello all')); - expect( - transport.sentLines, - contains('PRIVMSG #room :ACTION waves'), - ); - expect( - transport.sentLines, - contains('PRIVMSG #other :ACTION waves'), - ); + expect(transport.sentLines, contains('PRIVMSG #room :ACTION waves')); + expect(transport.sentLines, contains('PRIVMSG #other :ACTION waves')); controller.dispose(); }); @@ -3628,8 +3650,9 @@ void main() { final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room'); controller.selectTab(roomTab.id); - final contents = - controller.activeMessages.map((message) => message.content).toList(); + final contents = controller.activeMessages + .map((message) => message.content) + .toList(); expect(contents, contains('before')); expect(contents, isNot(contains('after'))); expect(contents, contains('hi')); @@ -3671,8 +3694,9 @@ void main() { final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room'); controller.selectTab(roomTab.id); - final contents = - controller.activeMessages.map((message) => message.content).toList(); + final contents = controller.activeMessages + .map((message) => message.content) + .toList(); expect(contents, isNot(contains('spam'))); expect(contents, contains('welcome')); @@ -3731,8 +3755,9 @@ void main() { await Future.delayed(Duration.zero); await controller.handleComposerSubmit('/clones #room'); - final contents = - controller.activeMessages.map((message) => message.content).join('\n'); + final contents = controller.activeMessages + .map((message) => message.content) + .join('\n'); expect(contents, contains('Clones detected in #room')); expect(contents, contains('shared.example')); expect(contents.contains('bob') && contents.contains('carol'), isTrue); @@ -3768,54 +3793,69 @@ void main() { controller.dispose(); }); - test('performs channel user actions through existing command paths', () async { - final transport = _FakeTransport(); - final service = IrcService(transportConnector: (_) async => transport); - final controller = ChatSessionController( - network: const NetworkConfig( - id: 'dbase', - name: 'DBase', - host: 'irc.example.test', - port: 6697, - nickname: 'AndroidIRCX', - altNickname: 'AndroidIRCX_', - ), - ircService: service, - ); + test( + 'performs channel user actions through existing command paths', + () async { + final transport = _FakeTransport(); + final service = IrcService(transportConnector: (_) async => transport); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + ); - await controller.start(); - transport.emit(':alice!user@example PRIVMSG #room :hi'); - await Future.delayed(Duration.zero); - final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room'); - controller.selectTab(roomTab.id); + await controller.start(); + transport.emit(':alice!user@example PRIVMSG #room :hi'); + await Future.delayed(Duration.zero); + final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room'); + controller.selectTab(roomTab.id); - await controller.performChannelUserAction('alice', ChannelUserAction.op); - await controller.performChannelUserAction('alice', ChannelUserAction.voice); - await controller.performChannelUserAction('@alice', ChannelUserAction.kick); - await controller.performChannelUserAction('alice', ChannelUserAction.ban); - await controller.performChannelUserAction('alice', ChannelUserAction.whois); - await controller.performChannelUserAction('alice', ChannelUserAction.query); + await controller.performChannelUserAction('alice', ChannelUserAction.op); + await controller.performChannelUserAction( + 'alice', + ChannelUserAction.voice, + ); + await controller.performChannelUserAction( + '@alice', + ChannelUserAction.kick, + ); + await controller.performChannelUserAction('alice', ChannelUserAction.ban); + await controller.performChannelUserAction( + 'alice', + ChannelUserAction.whois, + ); + await controller.performChannelUserAction( + 'alice', + ChannelUserAction.query, + ); - expect(transport.sentLines, contains('MODE #room +o alice')); - expect(transport.sentLines, contains('MODE #room +v alice')); - expect( - transport.sentLines.any((line) => line.startsWith('KICK #room alice')), - isTrue, - ); - expect(transport.sentLines, contains('MODE #room +b alice')); - expect( - transport.sentLines.any((line) => line.startsWith('WHOIS alice')), - isTrue, - ); - expect( - controller.tabs.any( - (tab) => tab.type.name == 'query' && tab.name == 'alice', - ), - isTrue, - ); + expect(transport.sentLines, contains('MODE #room +o alice')); + expect(transport.sentLines, contains('MODE #room +v alice')); + expect( + transport.sentLines.any((line) => line.startsWith('KICK #room alice')), + isTrue, + ); + expect(transport.sentLines, contains('MODE #room +b alice')); + expect( + transport.sentLines.any((line) => line.startsWith('WHOIS alice')), + isTrue, + ); + expect( + controller.tabs.any( + (tab) => tab.type.name == 'query' && tab.name == 'alice', + ), + isTrue, + ); - controller.dispose(); - }); + controller.dispose(); + }, + ); test('announces bouncer compatibility on registration', () async { final transport = _FakeTransport(); @@ -3874,8 +3914,9 @@ void main() { final roomTab = controller.tabs.firstWhere((tab) => tab.name == '#room'); controller.selectTab(roomTab.id); - final contents = - controller.activeMessages.map((message) => message.content).toList(); + final contents = controller.activeMessages + .map((message) => message.content) + .toList(); expect(contents, contains('hello spam word')); expect(contents, isNot(contains('another spam here'))); expect(contents, contains('clean message')); @@ -3977,10 +4018,7 @@ void main() { networkId: 'dbase', tabId: roomTab.id, ); - expect( - stored.any((message) => message.content == 'hello history'), - isTrue, - ); + expect(stored.any((message) => message.content == 'hello history'), isTrue); controller.dispose(); }); @@ -4027,8 +4065,9 @@ void main() { final roomTab = controller2.tabs.firstWhere((tab) => tab.name == '#room'); controller2.selectTab(roomTab.id); - final contents = - controller2.activeMessages.map((message) => message.content).toList(); + final contents = controller2.activeMessages + .map((message) => message.content) + .toList(); expect(contents, contains('first message')); expect(contents, contains('second message')); diff --git a/test/device_unlock_session_test.dart b/test/device_unlock_session_test.dart new file mode 100644 index 0000000..82074f8 --- /dev/null +++ b/test/device_unlock_session_test.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:androidircx/core/security/device_unlock_session.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('reuses a recent successful unlock', () async { + var now = DateTime(2026, 8, 24, 15); + var calls = 0; + final session = DeviceUnlockSession( + reuseWindow: const Duration(minutes: 1), + now: () => now, + prompt: (_) async { + calls++; + return true; + }, + ); + + expect(await session.authenticate(reason: 'Unlock app'), isTrue); + expect(await session.authenticate(reason: 'Unlock history'), isTrue); + expect(calls, 1); + + now = now.add(const Duration(minutes: 2)); + expect(await session.authenticate(reason: 'Unlock history'), isTrue); + expect(calls, 2); + }); + + test('coalesces concurrent unlock requests', () async { + var calls = 0; + final prompt = Completer(); + final session = DeviceUnlockSession( + prompt: (_) { + calls++; + return prompt.future; + }, + ); + + final first = session.authenticate(reason: 'Unlock app'); + final second = session.authenticate(reason: 'Unlock history'); + expect(calls, 1); + + prompt.complete(true); + expect(await first, isTrue); + expect(await second, isTrue); + }); + + test('invalidate clears the cached unlock', () async { + var calls = 0; + final session = DeviceUnlockSession( + prompt: (_) async { + calls++; + return true; + }, + ); + + expect(await session.authenticate(reason: 'Unlock app'), isTrue); + session.invalidate(); + expect(await session.authenticate(reason: 'Unlock app'), isTrue); + expect(calls, 2); + }); +}