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/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 @@ + { 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 863c810..6cff832 100644 --- a/lib/core/models/app_settings.dart +++ b/lib/core/models/app_settings.dart @@ -22,6 +22,8 @@ class AppSettings { this.nickColorMode = NickColorMode.soft, this.onboardingCompleted = false, this.appLockEnabled = false, + this.analyticsConsent = false, + this.notificationsEnabled = false, this.notifyHighlights = true, this.notifyPrivateMessages = true, this.notifyDccOffers = true, @@ -60,6 +62,14 @@ 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. + final bool notificationsEnabled; final bool notifyHighlights; final bool notifyPrivateMessages; final bool notifyDccOffers; @@ -102,6 +112,8 @@ class AppSettings { NickColorMode? nickColorMode, bool? onboardingCompleted, bool? appLockEnabled, + bool? analyticsConsent, + bool? notificationsEnabled, bool? notifyHighlights, bool? notifyPrivateMessages, bool? notifyDccOffers, @@ -140,6 +152,8 @@ 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: notifyPrivateMessages ?? this.notifyPrivateMessages, @@ -177,6 +191,8 @@ class AppSettings { 'nickColorMode': nickColorMode.name, 'onboardingCompleted': onboardingCompleted, 'appLockEnabled': appLockEnabled, + 'analyticsConsent': analyticsConsent, + 'notificationsEnabled': notificationsEnabled, 'notifyHighlights': notifyHighlights, 'notifyPrivateMessages': notifyPrivateMessages, 'notifyDccOffers': notifyDccOffers, @@ -232,6 +248,8 @@ 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, notifyDccOffers: (json['notifyDccOffers'] as bool?) ?? true, diff --git a/lib/core/platform/app_permissions.dart b/lib/core/platform/app_permissions.dart new file mode 100644 index 0000000..a044285 --- /dev/null +++ b/lib/core/platform/app_permissions.dart @@ -0,0 +1,53 @@ +import 'package:permission_handler/permission_handler.dart'; + +enum AppPermissionResult { granted, denied, permanentlyDenied, restricted } + +/// Thin wrapper over `permission_handler` so runtime-permission flows are +/// testable (the concrete implementation talks to the OS; tests inject a fake). +abstract class AppPermissions { + Future 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/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/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 64b1959..612e0de 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(); } @@ -27,7 +38,15 @@ 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; + + 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 +66,7 @@ class _OnboardingScreenState extends State { 'Set up your identity', 'Choose your network', 'Choose your channels', + 'Notifications', ]; @override @@ -96,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(); } @@ -228,11 +258,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( @@ -258,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( @@ -282,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 2df60d6..62c5c52 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,49 +471,107 @@ 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: _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, + ), + 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(notificationSound: value), + _settings.copyWith(analyticsConsent: value), ), ), ], @@ -704,17 +775,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 +814,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), @@ -811,6 +845,7 @@ class _SettingsScreenState extends State { _isLoading = false; _syncTextControllers(settings); }); + await _refreshPermissionStatuses(); } void _syncFromController() { @@ -913,6 +948,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. @@ -1031,41 +1146,12 @@ 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. 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. -'''; - -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. +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 ba01af8..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: @@ -744,6 +840,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..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 @@ -49,6 +49,11 @@ dependencies: image_picker: ^1.2.3 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/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..340e5db --- /dev/null +++ b/test/notification_permission_settings_test.dart @@ -0,0 +1,165 @@ +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('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( + 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/onboarding_permission_test.dart b/test/onboarding_permission_test.dart new file mode 100644 index 0000000..fc9981b --- /dev/null +++ b/test/onboarding_permission_test.dart @@ -0,0 +1,144 @@ +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).first); // privacy consent (terms) + 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('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 { + 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/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/test/widget_test.dart b/test/widget_test.dart index caeb0bc..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(); @@ -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(); @@ -726,34 +731,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 +752,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', ( diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 510fad9..30d4ad0 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,17 +7,26 @@ #include "generated_plugin_registrant.h" #include +#include +#include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FirebaseAppCheckPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseAppCheckPluginCApi")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( 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..030c229 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,8 +4,11 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows + firebase_app_check + firebase_core flutter_secure_storage_windows local_auth_windows + permission_handler_windows url_launcher_windows )