diff --git a/android/build.gradle b/android/build.gradle index 17b32f3..1447097 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -22,6 +22,11 @@ subprojects { sub -> sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + // Some plugins (e.g. app_settings) hard-pin an older compileSdk + // than their transitive AndroidX deps require, which fails the + // AAR metadata check. Align every plugin with the app's + // compileSdk (flutter.compileSdkVersion) so the build passes. + compileSdkVersion 36 } } sub.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart index db428cd..08580e4 100644 --- a/lib/push/push_registration.dart +++ b/lib/push/push_registration.dart @@ -333,10 +333,26 @@ class PushRegistration { } } + /// True when the OS notification permission is already granted + /// (`authorized`/`provisional`). Read-only — never triggers the OS prompt. + /// Used by the cold-start/self-heal path so it registers only for devices + /// that already opted in, leaving the actual prompt to the first Talk visit. + static Future isOsPermissionGranted() async { + try { + final settings = await FirebaseMessaging.instance + .getNotificationSettings(); + return settings.authorizationStatus == AuthorizationStatus.authorized || + settings.authorizationStatus == AuthorizationStatus.provisional; + } on Object { + return false; + } + } + /// Registers this device when push is both user-enabled and backend-capable. - /// Requests the OS notification permission first (covers iOS + Android 13); - /// an explicit denial skips registration entirely so NC/proxy never push to a - /// device that cannot display notifications. Safe to call on every start — + /// Only registers when the OS notification permission is *already* granted — + /// it never triggers the OS prompt itself. Requesting the permission is the + /// job of the first Talk visit (see `maybePromptTalkNotifications`), which + /// keeps the prompt out of the cold-start path. Safe to call on every start — /// Nextcloud dedups an unchanged registration — which also self-heals a /// device whose registration was lost. static Future syncSubscription({ @@ -344,8 +360,8 @@ class PushRegistration { required bool capable, }) async { if (!(enabled && capable)) return; - if (!await requestOsPermission()) { - log('Push: OS notification permission denied, skipping registration'); + if (!await isOsPermissionGranted()) { + log('Push: OS notification permission not granted, skipping registration'); return; } final registration = PushRegistration(); diff --git a/lib/push/push_status.dart b/lib/push/push_status.dart index 413a3c0..29098aa 100644 --- a/lib/push/push_status.dart +++ b/lib/push/push_status.dart @@ -160,7 +160,16 @@ class PushStatusRow { /// for informational details (e.g. the registered URL). final String? detail; - const PushStatusRow({required this.label, required this.state, this.detail}); + /// When true, the row offers a shortcut into the OS notification settings — + /// the only place the user can (re)grant the permission after a denial. + final bool opensNotificationSettings; + + const PushStatusRow({ + required this.label, + required this.state, + this.detail, + this.opensNotificationSettings = false, + }); } const _pendingDetail = @@ -181,6 +190,7 @@ List buildPushStatusRows(PushStatusReport r) => [ PushStatusRow( label: 'Benachrichtigungsberechtigung', state: r.osPermission, + opensNotificationSettings: true, detail: switch (r.osPermission) { PushCheck.ok => null, PushCheck.fail => diff --git a/lib/storage/notification_settings.dart b/lib/storage/notification_settings.dart index cf6a4b7..89817b4 100644 --- a/lib/storage/notification_settings.dart +++ b/lib/storage/notification_settings.dart @@ -5,12 +5,21 @@ part 'notification_settings.g.dart'; @JsonSerializable() class NotificationSettings { /// Whether push notifications are enabled. Defaults to `true` — the OS - /// permission prompt at login is now the gate, so there is no separate - /// in-app opt-in step anymore. + /// permission prompt on the first Talk visit is now the gate, so there is no + /// separate in-app opt-in step anymore. @JsonKey(defaultValue: true) bool enabled; - NotificationSettings({this.enabled = true}); + /// Whether the one-time notification-permission prompt shown on the first + /// Talk visit has already run. Prevents nagging the user on every visit and + /// keeps the OS prompt out of the cold-start path. + @JsonKey(defaultValue: false) + bool talkPermissionPromptShown; + + NotificationSettings({ + this.enabled = true, + this.talkPermissionPromptShown = false, + }); factory NotificationSettings.fromJson(Map json) => _$NotificationSettingsFromJson(json); diff --git a/lib/storage/notification_settings.g.dart b/lib/storage/notification_settings.g.dart index 6c053aa..e468d36 100644 --- a/lib/storage/notification_settings.g.dart +++ b/lib/storage/notification_settings.g.dart @@ -8,8 +8,15 @@ part of 'notification_settings.dart'; NotificationSettings _$NotificationSettingsFromJson( Map json, -) => NotificationSettings(enabled: json['enabled'] as bool? ?? true); +) => NotificationSettings( + enabled: json['enabled'] as bool? ?? true, + talkPermissionPromptShown: + json['talkPermissionPromptShown'] as bool? ?? false, +); Map _$NotificationSettingsToJson( NotificationSettings instance, -) => {'enabled': instance.enabled}; +) => { + 'enabled': instance.enabled, + 'talkPermissionPromptShown': instance.talkPermissionPromptShown, +}; diff --git a/lib/utils/downloads/download_manager.dart b/lib/utils/downloads/download_manager.dart index fa4b229..e485509 100644 --- a/lib/utils/downloads/download_manager.dart +++ b/lib/utils/downloads/download_manager.dart @@ -91,15 +91,11 @@ class DownloadManager { tapOpensFile: false, ); - // Notification permission is normally already granted via the FCM flow at - // login; request best-effort so downloads on a fresh install still notify. - try { - await bd.FileDownloader().permissions.request( - bd.PermissionType.notifications, - ); - } on Object catch (e) { - debugPrint('DownloadManager: notification permission request failed: $e'); - } + // Deliberately no notification-permission request here: it would fire the + // OS prompt at cold start (before login), which is exactly what we moved + // into the guided first-Talk-visit flow (see maybePromptTalkNotifications). + // Downloads still work without it; their progress notifications simply + // appear once the same POST_NOTIFICATIONS permission is granted there. } /// Active or recently finished job for [remotePath], or null if none. diff --git a/lib/view/pages/settings/widgets/push_status_sheet.dart b/lib/view/pages/settings/widgets/push_status_sheet.dart index 31e0552..276a618 100644 --- a/lib/view/pages/settings/widgets/push_status_sheet.dart +++ b/lib/view/pages/settings/widgets/push_status_sheet.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:app_settings/app_settings.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -42,16 +45,41 @@ class _PushStatusBody extends StatefulWidget { State<_PushStatusBody> createState() => _PushStatusBodyState(); } -class _PushStatusBodyState extends State<_PushStatusBody> { +class _PushStatusBodyState extends State<_PushStatusBody> + with WidgetsBindingObserver { PushStatusReport? _report; bool _busy = false; + /// Set while the user is in the OS settings — the next `resumed` lifecycle + /// event then re-collects the status so a just-changed permission shows up. + bool _reloadOnResume = false; + @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _load(); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed && _reloadOnResume) { + _reloadOnResume = false; + unawaited(_load()); + } + } + + Future _openNotificationSettings() async { + _reloadOnResume = true; + await AppSettings.openAppSettings(type: AppSettingsType.notification); + } + Future _load() async { final capabilitiesState = widget.capabilities.state; final report = await collectPushStatus( @@ -117,14 +145,24 @@ class _PushStatusBodyState extends State<_PushStatusBody> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ...rows.map( - (row) => ListTile( + ...rows.map((row) { + // Offer the OS-settings shortcut on the permission row whenever it + // isn't granted — after a denial that's the only place to fix it. + final canOpenSettings = + row.opensNotificationSettings && row.state != PushCheck.ok; + return ListTile( dense: true, leading: _stateIcon(row.state, theme), title: Text(row.label), subtitle: row.detail == null ? null : Text(row.detail!), - ), - ), + trailing: canOpenSettings + ? TextButton( + onPressed: _openNotificationSettings, + child: const Text('Einstellungen'), + ) + : null, + ); + }), if (report.general.lastRegistrationAt != null || report.talk.lastRegistrationAt != null) const Divider(height: 1), diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index 5309ee0..a927c45 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_split_view/flutter_split_view.dart'; @@ -11,6 +13,7 @@ import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../widget/confirm_dialog.dart'; import '../../../widget/placeholder_view.dart'; import 'join_chat.dart'; +import 'notification_permission_prompt.dart'; import 'search_chat.dart'; import 'widgets/chat_tile.dart'; import 'widgets/split_view_placeholder.dart'; @@ -44,6 +47,7 @@ class _ChatListViewState extends State<_ChatListView> { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _maybeOpenPendingChat(); + unawaited(maybePromptTalkNotifications(context)); }); } diff --git a/lib/view/pages/talk/notification_permission_prompt.dart b/lib/view/pages/talk/notification_permission_prompt.dart new file mode 100644 index 0000000..e197e5d --- /dev/null +++ b/lib/view/pages/talk/notification_permission_prompt.dart @@ -0,0 +1,89 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:app_settings/app_settings.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../push/push_registration.dart'; +import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../widget/confirm_dialog.dart'; + +/// Shows the one-time notification-permission flow on the first Talk visit. +/// +/// The OS prompt is deliberately kept out of the cold-start path (younger users +/// decline it reflexively before ever seeing why they'd want it). Instead, the +/// first time Talk is opened we explain the request, then trigger the OS prompt, +/// and — if declined — offer a shortcut to the system settings. +/// +/// Runs at most once per install (guarded by `talkPermissionPromptShown`). +Future maybePromptTalkNotifications(BuildContext context) async { + final settings = context.read(); + final notificationSettings = settings.val().notificationSettings; + + // Already handled once, or the user opted out of push entirely. + if (notificationSettings.talkPermissionPromptShown) return; + if (!notificationSettings.enabled) return; + + // Capabilities may still be loading on a fresh cold start; retry on the next + // Talk visit instead of burning the one-shot flag. + if (!context.read().canReceivePushNotifications) return; + + // Existing users who already granted the permission: register silently and + // mark the prompt as handled without showing any dialog. + if (await PushRegistration.isOsPermissionGranted()) { + settings.val(write: true).notificationSettings.talkPermissionPromptShown = + true; + unawaited(PushRegistration().register()); + return; + } + + if (!context.mounted) return; + + ConfirmDialog( + icon: Icons.notifications_active_outlined, + title: 'Benachrichtigungen aktivieren', + content: + 'Damit du keine neuen Nachrichten im Talk verpasst, fragen wir dich ' + 'gleich nach der Erlaubnis für Benachrichtigungen. Bitte akzeptiere ' + 'sie, um Push-Nachrichten zu erhalten.', + confirmButton: 'Weiter', + cancelButton: null, + onConfirm: () => unawaited(_requestPermission(context, settings)), + ).asDialog(context); +} + +Future _requestPermission( + BuildContext context, + SettingsCubit settings, +) async { + final granted = await PushRegistration.requestOsPermission(); + + // Mark handled regardless of the outcome — the user can re-enable later via + // the system settings; we don't want to prompt again on the next Talk visit. + settings.val(write: true).notificationSettings.talkPermissionPromptShown = + true; + + if (granted) { + unawaited(PushRegistration().register()); + return; + } + + log('Push: notification permission declined on first Talk visit'); + + if (!context.mounted) return; + + ConfirmDialog( + icon: Icons.notifications_off_outlined, + title: 'Benachrichtigungen deaktiviert', + content: + 'Ohne die Berechtigung erhältst du keine Benachrichtigungen bei neuen ' + 'Talk-Nachrichten. Du kannst sie jederzeit in den Systemeinstellungen ' + 'deines Geräts nachträglich aktivieren.', + confirmButton: 'Einstellungen öffnen', + cancelButton: 'Später', + onConfirm: () => + AppSettings.openAppSettings(type: AppSettingsType.notification), + ).asDialog(context); +} diff --git a/lib/widget/confirm_dialog.dart b/lib/widget/confirm_dialog.dart index e7f06e5..256146f 100644 --- a/lib/widget/confirm_dialog.dart +++ b/lib/widget/confirm_dialog.dart @@ -9,7 +9,10 @@ class ConfirmDialog extends StatelessWidget { final String content; final IconData? icon; final String confirmButton; - final String cancelButton; + + /// Label of the cancel button. Set to `null` for a single-button dialog + /// (only the confirm action is rendered). + final String? cancelButton; final void Function()? onConfirm; final AsyncActionCallback? onConfirmAsync; final AsyncErrorBuilder? errorBuilder; @@ -48,10 +51,11 @@ class ConfirmDialog extends StatelessWidget { ), ] : [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(cancelButton), - ), + if (cancelButton != null) + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(cancelButton!), + ), TextButton( onPressed: () { Haptics.confirm(); diff --git a/pubspec.yaml b/pubspec.yaml index fc3aab9..276ada9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -91,6 +91,9 @@ dependencies: chewie: ^1.8.5 flutter_native_splash: ^2.4.4 background_downloader: ^9.5.5 + # Opens the OS notification settings for this app (iOS + Android) when the + # user declined the permission and wants to enable it later. + app_settings: ^5.1.1 dev_dependencies: flutter_test: