implemented a guided notification permission flow triggered on the first Talk visit
This commit is contained in:
@@ -22,6 +22,11 @@ subprojects { sub ->
|
|||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = 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 {
|
sub.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
|
||||||
|
|||||||
@@ -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<bool> 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.
|
/// Registers this device when push is both user-enabled and backend-capable.
|
||||||
/// Requests the OS notification permission first (covers iOS + Android 13);
|
/// Only registers when the OS notification permission is *already* granted —
|
||||||
/// an explicit denial skips registration entirely so NC/proxy never push to a
|
/// it never triggers the OS prompt itself. Requesting the permission is the
|
||||||
/// device that cannot display notifications. Safe to call on every start —
|
/// 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
|
/// Nextcloud dedups an unchanged registration — which also self-heals a
|
||||||
/// device whose registration was lost.
|
/// device whose registration was lost.
|
||||||
static Future<void> syncSubscription({
|
static Future<void> syncSubscription({
|
||||||
@@ -344,8 +360,8 @@ class PushRegistration {
|
|||||||
required bool capable,
|
required bool capable,
|
||||||
}) async {
|
}) async {
|
||||||
if (!(enabled && capable)) return;
|
if (!(enabled && capable)) return;
|
||||||
if (!await requestOsPermission()) {
|
if (!await isOsPermissionGranted()) {
|
||||||
log('Push: OS notification permission denied, skipping registration');
|
log('Push: OS notification permission not granted, skipping registration');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final registration = PushRegistration();
|
final registration = PushRegistration();
|
||||||
|
|||||||
@@ -160,7 +160,16 @@ class PushStatusRow {
|
|||||||
/// for informational details (e.g. the registered URL).
|
/// for informational details (e.g. the registered URL).
|
||||||
final String? detail;
|
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 =
|
const _pendingDetail =
|
||||||
@@ -181,6 +190,7 @@ List<PushStatusRow> buildPushStatusRows(PushStatusReport r) => [
|
|||||||
PushStatusRow(
|
PushStatusRow(
|
||||||
label: 'Benachrichtigungsberechtigung',
|
label: 'Benachrichtigungsberechtigung',
|
||||||
state: r.osPermission,
|
state: r.osPermission,
|
||||||
|
opensNotificationSettings: true,
|
||||||
detail: switch (r.osPermission) {
|
detail: switch (r.osPermission) {
|
||||||
PushCheck.ok => null,
|
PushCheck.ok => null,
|
||||||
PushCheck.fail =>
|
PushCheck.fail =>
|
||||||
|
|||||||
@@ -5,12 +5,21 @@ part 'notification_settings.g.dart';
|
|||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class NotificationSettings {
|
class NotificationSettings {
|
||||||
/// Whether push notifications are enabled. Defaults to `true` — the OS
|
/// Whether push notifications are enabled. Defaults to `true` — the OS
|
||||||
/// permission prompt at login is now the gate, so there is no separate
|
/// permission prompt on the first Talk visit is now the gate, so there is no
|
||||||
/// in-app opt-in step anymore.
|
/// separate in-app opt-in step anymore.
|
||||||
@JsonKey(defaultValue: true)
|
@JsonKey(defaultValue: true)
|
||||||
bool enabled;
|
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<String, dynamic> json) =>
|
factory NotificationSettings.fromJson(Map<String, dynamic> json) =>
|
||||||
_$NotificationSettingsFromJson(json);
|
_$NotificationSettingsFromJson(json);
|
||||||
|
|||||||
@@ -8,8 +8,15 @@ part of 'notification_settings.dart';
|
|||||||
|
|
||||||
NotificationSettings _$NotificationSettingsFromJson(
|
NotificationSettings _$NotificationSettingsFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => NotificationSettings(enabled: json['enabled'] as bool? ?? true);
|
) => NotificationSettings(
|
||||||
|
enabled: json['enabled'] as bool? ?? true,
|
||||||
|
talkPermissionPromptShown:
|
||||||
|
json['talkPermissionPromptShown'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$NotificationSettingsToJson(
|
Map<String, dynamic> _$NotificationSettingsToJson(
|
||||||
NotificationSettings instance,
|
NotificationSettings instance,
|
||||||
) => <String, dynamic>{'enabled': instance.enabled};
|
) => <String, dynamic>{
|
||||||
|
'enabled': instance.enabled,
|
||||||
|
'talkPermissionPromptShown': instance.talkPermissionPromptShown,
|
||||||
|
};
|
||||||
|
|||||||
@@ -91,15 +91,11 @@ class DownloadManager {
|
|||||||
tapOpensFile: false,
|
tapOpensFile: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Notification permission is normally already granted via the FCM flow at
|
// Deliberately no notification-permission request here: it would fire the
|
||||||
// login; request best-effort so downloads on a fresh install still notify.
|
// OS prompt at cold start (before login), which is exactly what we moved
|
||||||
try {
|
// into the guided first-Talk-visit flow (see maybePromptTalkNotifications).
|
||||||
await bd.FileDownloader().permissions.request(
|
// Downloads still work without it; their progress notifications simply
|
||||||
bd.PermissionType.notifications,
|
// appear once the same POST_NOTIFICATIONS permission is granted there.
|
||||||
);
|
|
||||||
} on Object catch (e) {
|
|
||||||
debugPrint('DownloadManager: notification permission request failed: $e');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Active or recently finished job for [remotePath], or null if none.
|
/// Active or recently finished job for [remotePath], or null if none.
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:app_settings/app_settings.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
@@ -42,16 +45,41 @@ class _PushStatusBody extends StatefulWidget {
|
|||||||
State<_PushStatusBody> createState() => _PushStatusBodyState();
|
State<_PushStatusBody> createState() => _PushStatusBodyState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PushStatusBodyState extends State<_PushStatusBody> {
|
class _PushStatusBodyState extends State<_PushStatusBody>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
PushStatusReport? _report;
|
PushStatusReport? _report;
|
||||||
bool _busy = false;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_load();
|
_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<void> _openNotificationSettings() async {
|
||||||
|
_reloadOnResume = true;
|
||||||
|
await AppSettings.openAppSettings(type: AppSettingsType.notification);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
final capabilitiesState = widget.capabilities.state;
|
final capabilitiesState = widget.capabilities.state;
|
||||||
final report = await collectPushStatus(
|
final report = await collectPushStatus(
|
||||||
@@ -117,14 +145,24 @@ class _PushStatusBodyState extends State<_PushStatusBody> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
...rows.map(
|
...rows.map((row) {
|
||||||
(row) => ListTile(
|
// 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,
|
dense: true,
|
||||||
leading: _stateIcon(row.state, theme),
|
leading: _stateIcon(row.state, theme),
|
||||||
title: Text(row.label),
|
title: Text(row.label),
|
||||||
subtitle: row.detail == null ? null : Text(row.detail!),
|
subtitle: row.detail == null ? null : Text(row.detail!),
|
||||||
),
|
trailing: canOpenSettings
|
||||||
),
|
? TextButton(
|
||||||
|
onPressed: _openNotificationSettings,
|
||||||
|
child: const Text('Einstellungen'),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}),
|
||||||
if (report.general.lastRegistrationAt != null ||
|
if (report.general.lastRegistrationAt != null ||
|
||||||
report.talk.lastRegistrationAt != null)
|
report.talk.lastRegistrationAt != null)
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_split_view/flutter_split_view.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/confirm_dialog.dart';
|
||||||
import '../../../widget/placeholder_view.dart';
|
import '../../../widget/placeholder_view.dart';
|
||||||
import 'join_chat.dart';
|
import 'join_chat.dart';
|
||||||
|
import 'notification_permission_prompt.dart';
|
||||||
import 'search_chat.dart';
|
import 'search_chat.dart';
|
||||||
import 'widgets/chat_tile.dart';
|
import 'widgets/chat_tile.dart';
|
||||||
import 'widgets/split_view_placeholder.dart';
|
import 'widgets/split_view_placeholder.dart';
|
||||||
@@ -44,6 +47,7 @@ class _ChatListViewState extends State<_ChatListView> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_maybeOpenPendingChat();
|
_maybeOpenPendingChat();
|
||||||
|
unawaited(maybePromptTalkNotifications(context));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<void> maybePromptTalkNotifications(BuildContext context) async {
|
||||||
|
final settings = context.read<SettingsCubit>();
|
||||||
|
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<CapabilitiesCubit>().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<void> _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);
|
||||||
|
}
|
||||||
@@ -9,7 +9,10 @@ class ConfirmDialog extends StatelessWidget {
|
|||||||
final String content;
|
final String content;
|
||||||
final IconData? icon;
|
final IconData? icon;
|
||||||
final String confirmButton;
|
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 void Function()? onConfirm;
|
||||||
final AsyncActionCallback? onConfirmAsync;
|
final AsyncActionCallback? onConfirmAsync;
|
||||||
final AsyncErrorBuilder? errorBuilder;
|
final AsyncErrorBuilder? errorBuilder;
|
||||||
@@ -48,10 +51,11 @@ class ConfirmDialog extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
TextButton(
|
if (cancelButton != null)
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
TextButton(
|
||||||
child: Text(cancelButton),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
),
|
child: Text(cancelButton!),
|
||||||
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Haptics.confirm();
|
Haptics.confirm();
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ dependencies:
|
|||||||
chewie: ^1.8.5
|
chewie: ^1.8.5
|
||||||
flutter_native_splash: ^2.4.4
|
flutter_native_splash: ^2.4.4
|
||||||
background_downloader: ^9.5.5
|
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:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user