From 2dd79815239e1fc4a51cbc24ff529d091a640c84 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Sat, 23 Mar 2024 14:07:36 -0700 Subject: [PATCH 01/18] initial refactor to allow for push notifications --- ios/Podfile.lock | 8 +- ios/Runner.xcodeproj/project.pbxproj | 8 + ios/Runner/Runner.entitlements | 8 + lib/main.dart | 161 +++++------------ lib/settings/pages/general_settings_page.dart | 39 +++- lib/utils/notifications.dart | 168 +++++++++++++++++- pubspec.lock | 72 ++++++++ pubspec.yaml | 2 + 8 files changed, 335 insertions(+), 131 deletions(-) create mode 100644 ios/Runner/Runner.entitlements diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 7859321fa..f34d0e054 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -28,6 +28,8 @@ PODS: - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter + - push_ios (0.0.1): + - Flutter - receive_sharing_intent (0.0.1): - Flutter - share_plus (0.0.1): @@ -60,6 +62,7 @@ DEPENDENCIES: - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - push_ios (from `.symlinks/plugins/push_ios/ios`) - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) @@ -97,6 +100,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + push_ios: + :path: ".symlinks/plugins/push_ios/ios" receive_sharing_intent: :path: ".symlinks/plugins/receive_sharing_intent/ios" share_plus: @@ -127,6 +132,7 @@ SPEC CHECKSUMS: package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c permission_handler_apple: 036b856153a2b1f61f21030ff725f3e6fece2b78 + push_ios: 2bd1b4d3f782209da1f571db1250af236957e807 receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1 share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028 shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 @@ -137,4 +143,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 8d23d5c4d896af3a5f2a08e0206462ca9882e556 -COCOAPODS: 1.14.3 +COCOAPODS: 1.15.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index b257c1883..3dd324301 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -99,6 +99,7 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; CF3F7AD7E4C59A30FCCF598C /* Pods-Runner.debug-development.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug-development.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug-development.xcconfig"; sourceTree = ""; }; D2C3376210191F1E2A497D1B /* Pods-RunnerTests.debug-production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug-production.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug-production.xcconfig"; sourceTree = ""; }; + D41A83512BAF667A004F5C45 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; D458C5672B0D6F7D0090D826 /* Open In Thunder.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Open In Thunder.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; D458C5692B0D6F7D0090D826 /* SafariWebExtensionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariWebExtensionHandler.swift; sourceTree = ""; }; D458C56C2B0D6F7D0090D826 /* _locales */ = {isa = PBXFileReference; lastKnownFileType = folder; path = _locales; sourceTree = ""; }; @@ -195,6 +196,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + D41A83512BAF667A004F5C45 /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -610,6 +612,7 @@ APP_DISPLAY_NAME = Thunder; ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-production"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = L7P596HY6P; ENABLE_BITCODE = NO; @@ -797,6 +800,7 @@ APP_DISPLAY_NAME = Thunder; ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-production"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = L7P596HY6P; ENABLE_BITCODE = NO; @@ -822,6 +826,7 @@ APP_DISPLAY_NAME = Thunder; ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-production"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = L7P596HY6P; ENABLE_BITCODE = NO; @@ -901,6 +906,7 @@ APP_DISPLAY_NAME = Thunder; ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-development"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = L7P596HY6P; ENABLE_BITCODE = NO; @@ -1042,6 +1048,7 @@ APP_DISPLAY_NAME = Thunder; ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-development"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = L7P596HY6P; ENABLE_BITCODE = NO; @@ -1175,6 +1182,7 @@ APP_DISPLAY_NAME = Thunder; ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-development"; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = L7P596HY6P; ENABLE_BITCODE = NO; diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 000000000..903def2af --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/lib/main.dart b/lib/main.dart index 334cabc4c..6d3ae0408 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,29 +1,21 @@ -import 'dart:async'; import 'dart:io'; +import 'dart:async'; -import 'package:background_fetch/background_fetch.dart'; -import 'package:dart_ping_ios/dart_ping_ios.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; // External Packages +import 'package:dart_ping_ios/dart_ping_ios.dart'; +import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import "package:flutter_displaymode/flutter_displaymode.dart"; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:l10n_esperanto/l10n_esperanto.dart'; import 'package:overlay_support/overlay_support.dart'; -import 'package:dynamic_color/dynamic_color.dart'; -import 'package:flex_color_scheme/flex_color_scheme.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:thunder/account/bloc/account_bloc.dart'; -import 'package:thunder/community/bloc/anonymous_subscriptions_bloc.dart'; -import 'package:thunder/community/bloc/community_bloc.dart'; -import 'package:thunder/core/enums/local_settings.dart'; -import 'package:thunder/core/singletons/lemmy_client.dart'; -import 'package:thunder/core/singletons/preferences.dart'; -import 'package:thunder/instance/bloc/instance_bloc.dart'; // Internal Packages import 'package:thunder/routes.dart'; @@ -39,6 +31,13 @@ import 'package:thunder/utils/global_context.dart'; import 'package:flutter/foundation.dart'; import 'package:thunder/utils/notifications.dart'; import 'package:thunder/utils/preferences.dart'; +import 'package:thunder/account/bloc/account_bloc.dart'; +import 'package:thunder/community/bloc/community_bloc.dart'; +import 'package:thunder/core/enums/local_settings.dart'; +import 'package:thunder/core/singletons/lemmy_client.dart'; +import 'package:thunder/core/singletons/preferences.dart'; +import 'package:thunder/instance/bloc/instance_bloc.dart'; +import 'package:thunder/community/bloc/anonymous_subscriptions_bloc.dart'; void main() async { WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); @@ -47,9 +46,6 @@ void main() async { // Setting SystemUIMode SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - // Load up preferences - SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - // Load up sqlite database await DB.instance.database; @@ -61,60 +57,49 @@ void main() async { DartPingIOS.register(); } - /// Allows the top-level notification handlers to trigger actions farther down - final StreamController notificationsStreamController = StreamController(); - - bool startupDueToGroupNotification = false; - if (!kIsWeb && Platform.isAndroid) { - // Initialize local notifications. Note that this doesn't request permissions or actually send any notifications. - // It's just hooking up callbacks and settings. - final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - // Initialize the Android-specific settings, using the splash asset as the notification icon. - const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('splash'); - const InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid); - await flutterLocalNotificationsPlugin.initialize(initializationSettings, onDidReceiveNotificationResponse: (notificationResponse) => notificationsStreamController.add(notificationResponse)); - - // See if Thunder is launching because a notification was tapped. If so, we want to jump right to the appropriate page. - final NotificationAppLaunchDetails? notificationAppLaunchDetails = await flutterLocalNotificationsPlugin.getNotificationAppLaunchDetails(); - if (notificationAppLaunchDetails?.didNotificationLaunchApp == true && notificationAppLaunchDetails!.notificationResponse != null) { - notificationsStreamController.add(notificationAppLaunchDetails.notificationResponse!); - startupDueToGroupNotification = notificationAppLaunchDetails.notificationResponse!.payload == repliesGroupKey; - } - - // Initialize background fetch (this is async and can go run on its own). - if (prefs.getBool(LocalSettings.enableInboxNotifications.name) ?? false) { - initBackgroundFetch(); - } - } - final String initialInstance = (await UserPreferences.instance).sharedPreferences.getString(LocalSettings.currentAnonymousInstance.name) ?? 'lemmy.ml'; LemmyClient.instance.changeBaseUrl(initialInstance); // Perform preference migrations await performSharedPreferencesMigration(); - // Do a notifications check on startup, if the user isn't clicking on a group notification - if (!startupDueToGroupNotification) { - pollRepliesAndShowNotifications(); - } - - runApp(ThunderApp(notificationsStream: notificationsStreamController.stream)); + runApp(const ThunderApp()); if (!kIsWeb && Platform.isAndroid) { // Set high refresh rate after app initialization FlutterDisplayMode.setHighRefreshRate(); } +} - // Register to receive BackgroundFetch events after app is terminated. - if (!kIsWeb && Platform.isAndroid && (prefs.getBool(LocalSettings.enableInboxNotifications.name) ?? false)) { - initHeadlessBackgroundFetch(); - } +class ThunderApp extends StatefulWidget { + const ThunderApp({super.key}); + + @override + State createState() => _ThunderAppState(); } -class ThunderApp extends StatelessWidget { - final Stream notificationsStream; +class _ThunderAppState extends State { + /// Allows the top-level notification handlers to trigger actions farther down + final StreamController notificationsStreamController = StreamController(); + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; + if (prefs.getBool(LocalSettings.enableInboxNotifications.name) ?? false) { + // Initialize notification logic + initPushNotificationLogic(controller: notificationsStreamController); + } + }); + } - const ThunderApp({super.key, required this.notificationsStream}); + @override + void dispose() { + super.dispose(); + notificationsStreamController.close(); + } @override Widget build(BuildContext context) { @@ -133,7 +118,7 @@ class ThunderApp extends StatelessWidget { create: (context) => DeepLinksCubit(), ), BlocProvider( - create: (context) => NotificationsCubit(notificationsStream: notificationsStream), + create: (context) => NotificationsCubit(notificationsStream: notificationsStreamController.stream), ), BlocProvider( create: (context) => ThunderBloc(), @@ -229,67 +214,3 @@ class ThunderApp extends StatelessWidget { ); } } - -// ---------------- START BACKGROUND FETCH STUFF ---------------- // - -/// This method handles "headless" callbacks, -/// i.e., whent the app is not running -@pragma('vm:entry-point') -void backgroundFetchHeadlessTask(HeadlessTask task) async { - if (task.timeout) { - BackgroundFetch.finish(task.taskId); - return; - } - // Run the poll! - await pollRepliesAndShowNotifications(); - BackgroundFetch.finish(task.taskId); -} - -/// The method initializes background fetching while the app is running -Future initBackgroundFetch() async { - await BackgroundFetch.configure( - BackgroundFetchConfig( - minimumFetchInterval: 15, - stopOnTerminate: false, - startOnBoot: true, - enableHeadless: true, - requiredNetworkType: NetworkType.NONE, - requiresBatteryNotLow: false, - requiresStorageNotLow: false, - requiresCharging: false, - requiresDeviceIdle: false, - // Uncomment this line (and set the minimumFetchInterval to 1) for quicker testing. - //forceAlarmManager: true, - ), - // This is the callback that handles background fetching while the app is running. - (String taskId) async { - // Run the poll! - await pollRepliesAndShowNotifications(); - BackgroundFetch.finish(taskId); - }, - // This is the timeout callback. - (String taskId) async { - BackgroundFetch.finish(taskId); - }, - ); -} - -void disableBackgroundFetch() async { - await BackgroundFetch.configure( - BackgroundFetchConfig( - minimumFetchInterval: 15, - stopOnTerminate: true, - startOnBoot: false, - enableHeadless: false, - ), - () {}, - () {}, - ); -} - -// This method initializes background fetching while the app is not running -void initHeadlessBackgroundFetch() async { - BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask); -} - -// ---------------- END BACKGROUND FETCH STUFF ---------------- // diff --git a/lib/settings/pages/general_settings_page.dart b/lib/settings/pages/general_settings_page.dart index fc75d0ec3..21531dace 100644 --- a/lib/settings/pages/general_settings_page.dart +++ b/lib/settings/pages/general_settings_page.dart @@ -10,6 +10,7 @@ import 'package:android_intent_plus/android_intent.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:push/push.dart'; import 'package:thunder/core/enums/browser_mode.dart'; import 'package:thunder/core/enums/full_name.dart'; import 'package:thunder/core/enums/image_caching_mode.dart'; @@ -29,6 +30,7 @@ import 'package:thunder/utils/bottom_sheet_list_picker.dart'; import 'package:thunder/utils/constants.dart'; import 'package:thunder/utils/language/language.dart'; import 'package:thunder/utils/links.dart'; +import 'package:thunder/utils/notifications.dart'; class GeneralSettingsPage extends StatefulWidget { final LocalSettings? settingToHighlight; @@ -88,6 +90,9 @@ class _GeneralSettingsPageState extends State with SingleTi /// Not a setting, but tracks whether Android is allowing Thunder to send notifications bool? areAndroidNotificationsAllowed = false; + /// Not a setting, but tracks whether iOS is allowing Thunder to send notifications + UNNotificationSettings? areIOSNotificationsAllowed; + /// When enabled, authors and community names will be tappable when in compact view bool tappableAuthorCommunity = false; @@ -267,6 +272,12 @@ class _GeneralSettingsPageState extends State with SingleTi void _initPreferences() async { final prefs = (await UserPreferences.instance).sharedPreferences; + if (Platform.isIOS) { + Push.instance.getNotificationSettings().then((settings) => areIOSNotificationsAllowed = settings); + } else if (Platform.isAndroid) { + Push.instance.areNotificationsEnabled().then((areNotificationsEnabled) => areAndroidNotificationsAllowed = areNotificationsEnabled); + } + setState(() { // Default Sorts and Listing try { @@ -986,7 +997,7 @@ class _GeneralSettingsPageState extends State with SingleTi highlightKey: settingToHighlight == LocalSettings.showUpdateChangelogs ? settingToHighlightKey : null, ), ), - if (!kIsWeb && Platform.isAndroid) + if (!kIsWeb && Platform.isAndroid || Platform.isIOS) SliverToBoxAdapter( child: ToggleOption( description: l10n.enableInboxNotifications, @@ -1044,18 +1055,30 @@ class _GeneralSettingsPageState extends State with SingleTi areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.requestNotificationsPermission(); } + if (value) { + // Ensure that background fetching is enabled. + initBackgroundFetch(); + initHeadlessBackgroundFetch(); + } else { + // Ensure that background fetching is disabled. + disableBackgroundFetch(); + } + // This setState has no body because async operations aren't allowed, // but its purpose is to update areAndroidNotificationsAllowed. setState(() {}); } - if (value) { - // Ensure that background fetching is enabled. - initBackgroundFetch(); - initHeadlessBackgroundFetch(); - } else { - // Ensure that background fetching is disabled. - disableBackgroundFetch(); + if (!kIsWeb && Platform.isIOS && value) { + // We're on iOS. Request notifications permissions if needed. + final IOSFlutterLocalNotificationsPlugin? iosFlutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); + + await iosFlutterLocalNotificationsPlugin?.requestPermissions(alert: true, badge: true, sound: true); + + // This setState has no body because async operations aren't allowed, + // but its purpose is to update areIOSNotificationsAllowed. + setState(() {}); } }, subtitle: enableInboxNotifications diff --git a/lib/utils/notifications.dart b/lib/utils/notifications.dart index a712b94ae..5dfd02b21 100644 --- a/lib/utils/notifications.dart +++ b/lib/utils/notifications.dart @@ -1,7 +1,12 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:background_fetch/background_fetch.dart'; import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:html/parser.dart'; import 'package:lemmy_api_client/v3.dart'; +import 'package:push/push.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:markdown/markdown.dart'; @@ -19,6 +24,92 @@ const String repliesGroupKey = 'replies'; const String _lastPollTimeId = 'thunder_last_notifications_poll_time'; const int _repliesGroupSummaryId = 0; +/// Initialize iOS specific notification logic. This is only called when the app is running on iOS. +void initIOSPushNotificationLogic({required StreamController controller}) async { + // Fetch device token for APNs + final token = await Push.instance.token; + + /// We need to send this device token along with the jwt so that the server can poll for new notifications and send them to this device. + debugPrint("Device token: $token"); + + // Handle new tokens generated from the device + Push.instance.onNewToken.listen((token) { + /// We need to send this device token along with the jwt so that the server can poll for new notifications and send them to this device. + debugPrint("Received new device token: $token"); + }); + + // Handle notification launching app from terminated state + Push.instance.notificationTapWhichLaunchedAppFromTerminated.then((data) { + if (data == null) return; + + debugPrint('Notification was tapped notificationTapWhichLaunchedAppFromTerminated: Data: $data \n'); + if (data.containsKey(repliesGroupKey)) { + controller.add(NotificationResponse(payload: data[repliesGroupKey] as String, notificationResponseType: NotificationResponseType.selectedNotification)); + } + }); + + /// Handle notification taps. This triggers when the user taps on a notification when the app is on the foreground or background. + Push.instance.onNotificationTap.listen((data) { + debugPrint('Notification was tapped onNotificationTap: Data: $data \n'); + + if (data.containsKey(repliesGroupKey)) { + controller.add(NotificationResponse(payload: data[repliesGroupKey] as String, notificationResponseType: NotificationResponseType.selectedNotification)); + } + }); +} + +/// Initialize Android specific notification logic. This is only called when the app is running on Android. +void initAndroidPushNotificationLogic({required StreamController controller}) async { + // Load up preferences + SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; + + bool useUnifiedPush = false; + + if (useUnifiedPush) { + // TODO: Implement unified push + } else { + final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + + // Initialize the Android-specific settings, using the splash asset as the notification icon. + const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('splash'); + const InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid); + + await flutterLocalNotificationsPlugin.initialize( + initializationSettings, + onDidReceiveNotificationResponse: (notificationResponse) => controller.add(notificationResponse), + ); + + // See if Thunder is launching because a notification was tapped. If so, we want to jump right to the appropriate page. + final NotificationAppLaunchDetails? notificationAppLaunchDetails = await FlutterLocalNotificationsPlugin().getNotificationAppLaunchDetails(); + + if (notificationAppLaunchDetails?.didNotificationLaunchApp == true && notificationAppLaunchDetails!.notificationResponse != null) { + controller.add(notificationAppLaunchDetails.notificationResponse!); + + bool startupDueToGroupNotification = notificationAppLaunchDetails.notificationResponse!.payload == repliesGroupKey; + // Do a notifications check on startup, if the user isn't clicking on a group notification + if (!startupDueToGroupNotification) pollRepliesAndShowNotifications(); + } + + // Initialize background fetch (this is async and can go run on its own). + initBackgroundFetch(); + + // Register to receive BackgroundFetch events after app is terminated. + initHeadlessBackgroundFetch(); + } +} + +Future initPushNotificationLogic({required StreamController controller}) async { + if (Platform.isAndroid) { + initAndroidPushNotificationLogic(controller: controller); + } + + if (Platform.isIOS) { + initIOSPushNotificationLogic(controller: controller); + } +} + +// ---------------- ANDROID LOCAL NOTIFICATIONS FETCH LOGIC ---------------- // + /// This method polls for new inbox messages and, if found, displays them as notificatons. /// It is intended to be invoked from a background fetch task. /// If the user has not configured inbox notifications, it will do nothing. @@ -97,8 +188,17 @@ Future pollRepliesAndShowNotifications() async { // Create a summary notification for the group. // Note that it's ok to create this for every message, because it has a fixed ID, // so it will just get 'updated'. - final InboxStyleInformation inboxStyleInformationSummary = - InboxStyleInformation([], contentTitle: '', summaryText: generateUserFullName(null, account.username, account.instance, userSeparator: userSeparator)); + final InboxStyleInformation inboxStyleInformationSummary = InboxStyleInformation( + [], + contentTitle: '', + summaryText: generateUserFullName( + null, + account.username, + account.instance, + userSeparator: userSeparator, + ), + ); + final AndroidNotificationDetails androidNotificationDetailsSummary = AndroidNotificationDetails( _inboxMessagesChannelId, _inboxMessagesChannelName, @@ -106,6 +206,7 @@ Future pollRepliesAndShowNotifications() async { groupKey: repliesGroupKey, setAsGroupSummary: true, ); + final NotificationDetails notificationDetailsSummary = NotificationDetails(android: androidNotificationDetailsSummary); // Send the summary message! @@ -121,3 +222,66 @@ Future pollRepliesAndShowNotifications() async { // Save our poll time prefs.setString(_lastPollTimeId, DateTime.now().toString()); } + +// ---------------- ANDROID LOCAL NOTIFICATIONS BACKGROUND FETCH LOGIC ---------------- // + +/// This method handles "headless" callbacks (i.e., when the app is not running) +@pragma('vm:entry-point') +void backgroundFetchHeadlessTask(HeadlessTask task) async { + if (task.timeout) { + BackgroundFetch.finish(task.taskId); + return; + } + // Run the poll! + await pollRepliesAndShowNotifications(); + BackgroundFetch.finish(task.taskId); +} + +/// The method initializes background fetching while the app is running +Future initBackgroundFetch() async { + await BackgroundFetch.configure( + BackgroundFetchConfig( + minimumFetchInterval: 1, + stopOnTerminate: false, + startOnBoot: true, + enableHeadless: true, + requiredNetworkType: NetworkType.NONE, + requiresBatteryNotLow: false, + requiresStorageNotLow: false, + requiresCharging: false, + requiresDeviceIdle: false, + // Uncomment this line (and set the minimumFetchInterval to 1) for quicker testing. + forceAlarmManager: true, + ), + // This is the callback that handles background fetching while the app is running. + (String taskId) async { + // Run the poll! + await pollRepliesAndShowNotifications(); + BackgroundFetch.finish(taskId); + }, + // This is the timeout callback. + (String taskId) async { + BackgroundFetch.finish(taskId); + }, + ); +} + +void disableBackgroundFetch() async { + await BackgroundFetch.configure( + BackgroundFetchConfig( + minimumFetchInterval: 15, + stopOnTerminate: true, + startOnBoot: false, + enableHeadless: false, + ), + () {}, + () {}, + ); +} + +// This method initializes background fetching while the app is not running +void initHeadlessBackgroundFetch() async { + BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask); +} + +// ---------------- END BACKGROUND FETCH STUFF ---------------- // diff --git a/pubspec.lock b/pubspec.lock index f51ed6540..7fee83304 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -337,6 +337,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" + dependency_validator: + dependency: transitive + description: + name: dependency_validator + sha256: f727a5627aa405965fab4aef4f468e50a9b632ba0737fd2f98c932fec6d712b9 + url: "https://pub.dev" + source: hosted + version: "3.2.3" dev_build: dependency: transitive description: @@ -1345,6 +1353,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.3" + push: + dependency: "direct main" + description: + name: push + sha256: "15f47b829ac0a0c4ae592ae5d7f1b57cf27f8637744a4f2fd5f4f5dd1563a0d0" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + push_android: + dependency: transitive + description: + name: push_android + sha256: e3ad795fc758363c5f1c1aab3af0fff10e50bd1e7cd23a51ed6cc3825673b107 + url: "https://pub.dev" + source: hosted + version: "0.5.0" + push_ios: + dependency: transitive + description: + name: push_ios + sha256: c6a284994ef8a2e09c4e92c89317b14912b56cb74b31fb2efba7b9d2b8f4ff60 + url: "https://pub.dev" + source: hosted + version: "0.4.0" + push_macos: + dependency: transitive + description: + name: push_macos + sha256: "800997d1d6ca19aa957ab237a25074a732a7e36ef917ef4546fc75ec9aa1b9da" + url: "https://pub.dev" + source: hosted + version: "0.0.1" + push_platform_interface: + dependency: transitive + description: + name: push_platform_interface + sha256: "709c98b6e33cb0d76aa1e9017d0b16c17c077d0d0035a7b63d41ba19822a805e" + url: "https://pub.dev" + source: hosted + version: "0.5.0" receive_sharing_intent: dependency: "direct main" description: @@ -1664,6 +1712,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.0" + unifiedpush: + dependency: "direct main" + description: + name: unifiedpush + sha256: ef7f3ae6139d27169604e3844379ef7929af573a2be21d9e82187f44ab7b9a32 + url: "https://pub.dev" + source: hosted + version: "5.0.1" + unifiedpush_android: + dependency: transitive + description: + name: unifiedpush_android + sha256: "610ad746294541f56d632adf9afba5d1c164c44e23ec0dd2162a41a6ff00a00e" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + unifiedpush_platform_interface: + dependency: transitive + description: + name: unifiedpush_platform_interface + sha256: "7782b18a15d22bb184fa766ef1e0c675eef862055ff815453df7041dfd026146" + url: "https://pub.dev" + source: hosted + version: "2.0.1" universal_html: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6cc8cdcd2..b4264d407 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -108,6 +108,8 @@ dependencies: gal: ^2.2.0 smooth_highlight: ^0.1.1 visibility_detector: ^0.4.0+2 + push: ^2.1.0 + unifiedpush: ^5.0.1 dev_dependencies: build_runner: ^2.4.6 From 9a9ce07bf137aa15ae14eb26e786c15a1a0fdc15 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Mon, 25 Mar 2024 08:24:34 -0700 Subject: [PATCH 02/18] temp --- android/.gitignore | 1 + android/app/src/main/AndroidManifest.xml | 3 +- android/build.gradle | 15 +++++++-- android/gradle.properties | 3 ++ .../gradle/wrapper/gradle-wrapper.properties | 3 +- lib/settings/pages/general_settings_page.dart | 31 +++++++++++++++++++ lib/utils/notifications.dart | 27 ++++++++++++++-- 7 files changed, 76 insertions(+), 7 deletions(-) diff --git a/android/.gitignore b/android/.gitignore index 6f568019d..9e30584d2 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -5,6 +5,7 @@ gradle-wrapper.jar /gradlew.bat /local.properties GeneratedPluginRegistrant.java +/javaToolchains # Remember to never publicly share your keystore. # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 43e146777..e2da514c8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools"> diff --git a/android/build.gradle b/android/build.gradle index 6cca680a5..091fa824b 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.7.10' + ext.kotlin_version = '1.8.0' // Versions recommended/needed by flutter_local_notifications and background_fetch // These can be upgraded over time. ext { @@ -14,7 +14,7 @@ buildscript { dependencies { // flutter_local_notifications requires gradle version 7.3.1 - classpath 'com.android.tools.build:gradle:7.3.1' + classpath 'com.android.tools.build:gradle:8.1.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -28,6 +28,17 @@ allprojects { url "${project(':background_fetch').projectDir}/libs" } } + subprojects { + afterEvaluate { project -> + if (project.hasProperty('android')) { + project.android { + if (namespace == null) { + namespace project.group + } + } + } + } + } } rootProject.buildDir = '../build' diff --git a/android/gradle.properties b/android/gradle.properties index 94adc3a3f..b9a9a2464 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,6 @@ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true +android.defaults.buildfeatures.buildconfig=true +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 3c472b99c..305b7572f 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Mon Mar 25 08:08:23 PDT 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip diff --git a/lib/settings/pages/general_settings_page.dart b/lib/settings/pages/general_settings_page.dart index 21531dace..656bf6e8c 100644 --- a/lib/settings/pages/general_settings_page.dart +++ b/lib/settings/pages/general_settings_page.dart @@ -997,6 +997,37 @@ class _GeneralSettingsPageState extends State with SingleTi highlightKey: settingToHighlight == LocalSettings.showUpdateChangelogs ? settingToHighlightKey : null, ), ), + SliverToBoxAdapter( + child: ListOption( + description: l10n.enableInboxNotifications, + value: const ListPickerItem(payload: -1), + icon: Icons.notifications_on_rounded, + highlightKey: settingToHighlight == LocalSettings.enableInboxNotifications ? settingToHighlightKey : null, + customListPicker: StatefulBuilder( + builder: (context, setState) { + return BottomSheetListPicker( + title: l10n.dividerAppearance, + heading: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.preview, style: theme.textTheme.titleMedium), + const SizedBox(height: 20.0), + const SizedBox(height: 16.0), + ], + ), + items: [ + ListPickerItem( + customWidget: ListTile( + title: Text(l10n.color), + ), + payload: -1, + ), + ], + ); + }, + ), + ), + ), if (!kIsWeb && Platform.isAndroid || Platform.isIOS) SliverToBoxAdapter( child: ToggleOption( diff --git a/lib/utils/notifications.dart b/lib/utils/notifications.dart index 5dfd02b21..3185ecbfd 100644 --- a/lib/utils/notifications.dart +++ b/lib/utils/notifications.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:typed_data'; import 'package:background_fetch/background_fetch.dart'; import 'package:flutter/material.dart'; @@ -17,6 +18,7 @@ import 'package:thunder/core/enums/local_settings.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/utils/instance.dart'; +import 'package:unifiedpush/unifiedpush.dart'; const String _inboxMessagesChannelId = 'inbox_messages'; const String _inboxMessagesChannelName = 'Inbox Messages'; @@ -24,6 +26,11 @@ const String repliesGroupKey = 'replies'; const String _lastPollTimeId = 'thunder_last_notifications_poll_time'; const int _repliesGroupSummaryId = 0; +// UnifiedPush variables +var instance = "myInstance"; +var endpoint = ""; +var registered = false; + /// Initialize iOS specific notification logic. This is only called when the app is running on iOS. void initIOSPushNotificationLogic({required StreamController controller}) async { // Fetch device token for APNs @@ -58,15 +65,31 @@ void initIOSPushNotificationLogic({required StreamController controller}) async { // Load up preferences SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - bool useUnifiedPush = false; + bool useUnifiedPush = true; if (useUnifiedPush) { - // TODO: Implement unified push + UnifiedPush.initialize( + onNewEndpoint: (String _endpoint, String _instance) { + if (_instance != instance) return; + registered = true; + endpoint = _endpoint; + debugPrint(endpoint); + }, + onRegistrationFailed: onUnregistered, + onUnregistered: onUnregistered, + onMessage: (Uint8List message, String instance) {}, + ); } else { final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); From 12d29a6d1111888e422af1ef0affef7794f18135 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Mon, 25 Mar 2024 11:53:58 -0700 Subject: [PATCH 03/18] used fix version of push --- android/.gitignore | 1 - android/build.gradle | 11 ----------- pubspec.lock | 32 +++++++++++--------------------- pubspec.yaml | 11 ++++++++++- 4 files changed, 21 insertions(+), 34 deletions(-) diff --git a/android/.gitignore b/android/.gitignore index 9e30584d2..6f568019d 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -5,7 +5,6 @@ gradle-wrapper.jar /gradlew.bat /local.properties GeneratedPluginRegistrant.java -/javaToolchains # Remember to never publicly share your keystore. # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app diff --git a/android/build.gradle b/android/build.gradle index ea83a3eed..429e78cc6 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -38,17 +38,6 @@ allprojects { } } } - subprojects { - afterEvaluate { project -> - if (project.hasProperty('android')) { - project.android { - if (namespace == null) { - namespace project.group - } - } - } - } - } } rootProject.buildDir = '../build' diff --git a/pubspec.lock b/pubspec.lock index d67db26a1..6d8fa47cc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1361,22 +1361,23 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.3" -<<<<<<< HEAD push: dependency: "direct main" description: - name: push - sha256: "15f47b829ac0a0c4ae592ae5d7f1b57cf27f8637744a4f2fd5f4f5dd1563a0d0" - url: "https://pub.dev" - source: hosted + path: push + ref: "722c5e8541e1e5c91c56743b94d07f51f26fd2c5" + resolved-ref: "722c5e8541e1e5c91c56743b94d07f51f26fd2c5" + url: "https://github.com/hjiangsu/push.git" + source: git version: "2.1.0" push_android: - dependency: transitive + dependency: "direct overridden" description: - name: push_android - sha256: e3ad795fc758363c5f1c1aab3af0fff10e50bd1e7cd23a51ed6cc3825673b107 - url: "https://pub.dev" - source: hosted + path: push_android + ref: "722c5e8541e1e5c91c56743b94d07f51f26fd2c5" + resolved-ref: "722c5e8541e1e5c91c56743b94d07f51f26fd2c5" + url: "https://github.com/hjiangsu/push.git" + source: git version: "0.5.0" push_ios: dependency: transitive @@ -1402,17 +1403,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" - receive_sharing_intent: - dependency: "direct main" - description: - path: "." - ref: HEAD - resolved-ref: abedc3d6d5b80df396e9c300fd1a052cbab08827 - url: "https://github.com/AyushmanG26/receive_sharing_intent.git" - source: git - version: "1.4.5" -======= ->>>>>>> ef5e8f75d4e93195251bca3c839dd2174a930ea2 rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6f4f25d64..a899b07e9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -105,7 +105,11 @@ dependencies: gal: ^2.2.0 smooth_highlight: ^0.1.1 visibility_detector: ^0.4.0+2 - push: ^2.1.0 + push: + git: + url: https://github.com/hjiangsu/push.git + ref: 722c5e8541e1e5c91c56743b94d07f51f26fd2c5 + path: push unifiedpush: ^5.0.1 flutter_sharing_intent: ^1.1.1 @@ -121,6 +125,11 @@ dev_dependencies: dependency_overrides: markdown: 7.0.0 + push_android: + git: + url: https://github.com/hjiangsu/push.git + ref: 722c5e8541e1e5c91c56743b94d07f51f26fd2c5 + path: push_android # The following section is specific to Flutter packages. flutter: From 8e4ddcc191ddb89fd4c43f9547578987d0d3d621 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Mon, 25 Mar 2024 17:43:16 -0700 Subject: [PATCH 04/18] improved logic for android notifications --- lib/core/enums/local_settings.dart | 4 +- lib/core/enums/notification_type.dart | 1 + lib/main.dart | 4 +- lib/settings/pages/general_settings_page.dart | 284 +++++++++++------- lib/thunder/bloc/thunder_bloc.dart | 5 +- lib/thunder/bloc/thunder_state.dart | 10 +- lib/utils/notifications.dart | 282 +++++++++-------- lib/utils/preferences.dart | 8 + pubspec.lock | 4 +- pubspec.yaml | 2 +- 10 files changed, 355 insertions(+), 249 deletions(-) create mode 100644 lib/core/enums/notification_type.dart diff --git a/lib/core/enums/local_settings.dart b/lib/core/enums/local_settings.dart index fb3f9649f..9587d8873 100644 --- a/lib/core/enums/local_settings.dart +++ b/lib/core/enums/local_settings.dart @@ -96,8 +96,10 @@ enum LocalSettings { showUpdateChangelogs(name: 'setting_show_update_changelogs', key: 'showUpdateChangelogs', category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.notifications), scoreCounters(name: 'setting_score_counters', key: "showScoreCounters", category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.feed), appLanguageCode(name: 'setting_app_language_code', key: 'appLanguage', category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.feedTypeAndSorts), + @Deprecated('Use inboxNotificationType instead') enableInboxNotifications( name: 'setting_enable_inbox_notifications', key: 'enableInboxNotifications', category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.notifications), + inboxNotificationType(name: 'setting_inbox_notification_type', key: 'inboxNotificationType', category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.notifications), /// -------------------------- Feed Post Related Settings -------------------------- // Compact Related Settings @@ -307,7 +309,7 @@ extension LocalizationExt on AppLocalizations { 'markPostAsReadOnScroll': markPostAsReadOnScroll, 'showInAppUpdateNotifications': showInAppUpdateNotifications, 'showUpdateChangelogs': showUpdateChangelogs, - 'enableInboxNotifications': enableInboxNotifications, + 'inboxNotificationType': enableInboxNotifications, 'showScoreCounters': showScoreCounters, 'appLanguage': appLanguage, 'compactView': compactView, diff --git a/lib/core/enums/notification_type.dart b/lib/core/enums/notification_type.dart new file mode 100644 index 000000000..421265d0b --- /dev/null +++ b/lib/core/enums/notification_type.dart @@ -0,0 +1 @@ +enum NotificationType { none, local, unifiedPush, apn } diff --git a/lib/main.dart b/lib/main.dart index 6d3ae0408..586c49bd3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:l10n_esperanto/l10n_esperanto.dart'; import 'package:overlay_support/overlay_support.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:thunder/core/enums/notification_type.dart'; // Internal Packages import 'package:thunder/routes.dart'; @@ -88,7 +89,8 @@ class _ThunderAppState extends State { WidgetsBinding.instance.addPostFrameCallback((_) async { SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - if (prefs.getBool(LocalSettings.enableInboxNotifications.name) ?? false) { + print(prefs.getString(LocalSettings.inboxNotificationType.name)); + if (NotificationType.values.byName(prefs.getString(LocalSettings.inboxNotificationType.name) ?? NotificationType.none.name) != NotificationType.none) { // Initialize notification logic initPushNotificationLogic(controller: notificationsStreamController); } diff --git a/lib/settings/pages/general_settings_page.dart b/lib/settings/pages/general_settings_page.dart index 656bf6e8c..1c1734155 100644 --- a/lib/settings/pages/general_settings_page.dart +++ b/lib/settings/pages/general_settings_page.dart @@ -16,6 +16,7 @@ import 'package:thunder/core/enums/full_name.dart'; import 'package:thunder/core/enums/image_caching_mode.dart'; import 'package:thunder/core/enums/local_settings.dart'; +import 'package:thunder/core/enums/notification_type.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/main.dart'; @@ -31,6 +32,7 @@ import 'package:thunder/utils/constants.dart'; import 'package:thunder/utils/language/language.dart'; import 'package:thunder/utils/links.dart'; import 'package:thunder/utils/notifications.dart'; +import 'package:unifiedpush/unifiedpush.dart'; class GeneralSettingsPage extends StatefulWidget { final LocalSettings? settingToHighlight; @@ -85,7 +87,7 @@ class _GeneralSettingsPageState extends State with SingleTi bool showUpdateChangelogs = true; /// When enabled, system-level notifications will be displayed for new inbox messages - bool enableInboxNotifications = false; + NotificationType inboxNotificationType = NotificationType.none; /// Not a setting, but tracks whether Android is allowing Thunder to send notifications bool? areAndroidNotificationsAllowed = false; @@ -213,9 +215,9 @@ class _GeneralSettingsPageState extends State with SingleTi await prefs.setBool(LocalSettings.showUpdateChangelogs.name, value); setState(() => showUpdateChangelogs = value); break; - case LocalSettings.enableInboxNotifications: - await prefs.setBool(LocalSettings.enableInboxNotifications.name, value); - setState(() => enableInboxNotifications = value); + case LocalSettings.inboxNotificationType: + await prefs.setString(LocalSettings.inboxNotificationType.name, (value as NotificationType).name); + setState(() => inboxNotificationType = value); break; case LocalSettings.userFormat: @@ -321,7 +323,7 @@ class _GeneralSettingsPageState extends State with SingleTi showInAppUpdateNotification = prefs.getBool(LocalSettings.showInAppUpdateNotification.name) ?? false; showUpdateChangelogs = prefs.getBool(LocalSettings.showUpdateChangelogs.name) ?? true; - enableInboxNotifications = prefs.getBool(LocalSettings.enableInboxNotifications.name) ?? false; + inboxNotificationType = NotificationType.values.byName(prefs.getString(LocalSettings.inboxNotificationType.name) ?? NotificationType.none.name); }); } @@ -1002,124 +1004,176 @@ class _GeneralSettingsPageState extends State with SingleTi description: l10n.enableInboxNotifications, value: const ListPickerItem(payload: -1), icon: Icons.notifications_on_rounded, - highlightKey: settingToHighlight == LocalSettings.enableInboxNotifications ? settingToHighlightKey : null, + highlightKey: settingToHighlight == LocalSettings.inboxNotificationType ? settingToHighlightKey : null, customListPicker: StatefulBuilder( builder: (context, setState) { - return BottomSheetListPicker( - title: l10n.dividerAppearance, - heading: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.preview, style: theme.textTheme.titleMedium), - const SizedBox(height: 20.0), - const SizedBox(height: 16.0), - ], + return BottomSheetListPicker( + title: "Push Notifications", + heading: const Align( + alignment: Alignment.centerLeft, + child: Text("Using this feature requires the server to store your JWT token in order to poll for notifications. A restart of the app is required for this to take effect."), ), - items: [ - ListPickerItem( - customWidget: ListTile( - title: Text(l10n.color), - ), - payload: -1, - ), - ], - ); - }, - ), - ), - ), - if (!kIsWeb && Platform.isAndroid || Platform.isIOS) - SliverToBoxAdapter( - child: ToggleOption( - description: l10n.enableInboxNotifications, - value: enableInboxNotifications, - iconEnabled: Icons.notifications_on_rounded, - iconDisabled: Icons.notifications_off_rounded, - onToggle: (bool value) async { - // Show a warning message about the experimental nature of this feature. - // This message is specific to Android. - if (!kIsWeb && Platform.isAndroid && value) { - bool res = false; - await showThunderDialog( - context: context, - title: l10n.warning, - contentWidgetBuilder: (_) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(l10n.notificationsWarningDialog), - const SizedBox(height: 10), - Align( - alignment: Alignment.centerLeft, - child: GestureDetector( - onTap: () => handleLink(context, url: 'https://dontkillmyapp.com/'), - child: Text( - 'https://dontkillmyapp.com/', - style: theme.textTheme.bodyMedium?.copyWith(color: Colors.blue), - ), + previouslySelected: inboxNotificationType, + items: Platform.isAndroid + ? [ + const ListPickerItem( + icon: Icons.notifications_off_rounded, + label: "None", + payload: NotificationType.none, + capitalizeLabel: true, + ), + const ListPickerItem( + icon: Icons.notifications_rounded, + label: "Use Local Notifications (Experimental)", + subtitle: "Periodically checks for notifications in the background", + payload: NotificationType.local, + capitalizeLabel: true, + ), + const ListPickerItem( + icon: Icons.notifications_active_rounded, + label: "Use UnifiedPush Notifications", + subtitle: "Requires a compatible UnifiedPush app", + payload: NotificationType.unifiedPush, + capitalizeLabel: true, + ), + ] + : [ + const ListPickerItem( + icon: Icons.notifications_off_rounded, + label: "Disable Push Notifications", + payload: NotificationType.none, + capitalizeLabel: true, + ), + const ListPickerItem( + icon: Icons.notifications_active_rounded, + label: "Use UnifiedPush Notifications", + subtitle: "Requires a compatible UnifiedPush app", + payload: NotificationType.apn, + capitalizeLabel: true, ), + ], + onSelect: (ListPickerItem notificationType) async { + if (notificationType.payload == NotificationType.local) { + bool res = false; + await showThunderDialog( + context: context, + title: l10n.warning, + contentWidgetBuilder: (_) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.notificationsWarningDialog), + const SizedBox(height: 10), + Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + onTap: () => handleLink(context, url: 'https://dontkillmyapp.com/'), + child: Text( + 'https://dontkillmyapp.com/', + style: theme.textTheme.bodyMedium?.copyWith(color: Colors.blue), + ), + ), + ), + ], ), - ], - ), - primaryButtonText: l10n.understandEnable, - onPrimaryButtonPressed: (dialogContext, _) { - res = true; - dialogContext.pop(); - }, - secondaryButtonText: l10n.disable, - onSecondaryButtonPressed: (dialogContext) => dialogContext.pop(), - ); - - // The user chose not to enable the feature - if (!res) return; - } - - setPreferences(LocalSettings.enableInboxNotifications, value); - - if (!kIsWeb && Platform.isAndroid && value) { - // We're on Android. Request notifications permissions if needed. - // This is a no-op if on SDK version < 33 - final AndroidFlutterLocalNotificationsPlugin? androidFlutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); - - areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.areNotificationsEnabled(); - if (areAndroidNotificationsAllowed != true) { - areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.requestNotificationsPermission(); - } - - if (value) { - // Ensure that background fetching is enabled. - initBackgroundFetch(); - initHeadlessBackgroundFetch(); - } else { - // Ensure that background fetching is disabled. - disableBackgroundFetch(); - } - - // This setState has no body because async operations aren't allowed, - // but its purpose is to update areAndroidNotificationsAllowed. - setState(() {}); - } - - if (!kIsWeb && Platform.isIOS && value) { - // We're on iOS. Request notifications permissions if needed. - final IOSFlutterLocalNotificationsPlugin? iosFlutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); - - await iosFlutterLocalNotificationsPlugin?.requestPermissions(alert: true, badge: true, sound: true); - - // This setState has no body because async operations aren't allowed, - // but its purpose is to update areIOSNotificationsAllowed. - setState(() {}); - } + primaryButtonText: l10n.understandEnable, + onPrimaryButtonPressed: (dialogContext, _) { + res = true; + dialogContext.pop(); + }, + secondaryButtonText: l10n.cancel, + onSecondaryButtonPressed: (dialogContext) => dialogContext.pop(), + ); + + // The user chose not to enable the feature + if (!res) return; + + // Enable local notifications + initBackgroundFetch(); + initHeadlessBackgroundFetch(); + } else if (notificationType.payload == NotificationType.unifiedPush) { + // UnifiedPush.registerAppWithDialog(context, instance, []); + + // Disable local notifications + disableBackgroundFetch(); + } + + if (notificationType.payload == NotificationType.local || notificationType.payload == NotificationType.unifiedPush) { + // We're on Android. Request notifications permissions if needed. + // This is a no-op if on SDK version < 33 + final AndroidFlutterLocalNotificationsPlugin? androidFlutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); + + areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.areNotificationsEnabled(); + if (areAndroidNotificationsAllowed != true) { + areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.requestNotificationsPermission(); + } + } + + setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); + }, + ); }, - subtitle: enableInboxNotifications - ? !kIsWeb && Platform.isAndroid && areAndroidNotificationsAllowed == true - ? null - : l10n.notificationsNotAllowed - : null, - highlightKey: settingToHighlight == LocalSettings.enableInboxNotifications ? settingToHighlightKey : null, ), ), + ), + // if (!kIsWeb && Platform.isAndroid || Platform.isIOS) + // SliverToBoxAdapter( + // child: ToggleOption( + // description: l10n.enableInboxNotifications, + // value: enableInboxNotifications, + // iconEnabled: Icons.notifications_on_rounded, + // iconDisabled: Icons.notifications_off_rounded, + // onToggle: (bool value) async { + // // Show a warning message about the experimental nature of this feature. + // // This message is specific to Android. + + // setPreferences(LocalSettings.enableInboxNotifications, value); + + // if (!kIsWeb && Platform.isAndroid && value) { + // // We're on Android. Request notifications permissions if needed. + // // This is a no-op if on SDK version < 33 + // final AndroidFlutterLocalNotificationsPlugin? androidFlutterLocalNotificationsPlugin = + // FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); + + // areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.areNotificationsEnabled(); + // if (areAndroidNotificationsAllowed != true) { + // areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.requestNotificationsPermission(); + // } + + // if (value) { + // // Ensure that background fetching is enabled. + // initBackgroundFetch(); + // initHeadlessBackgroundFetch(); + // } else { + // // Ensure that background fetching is disabled. + // disableBackgroundFetch(); + // } + + // // This setState has no body because async operations aren't allowed, + // // but its purpose is to update areAndroidNotificationsAllowed. + // setState(() {}); + // } + + // if (!kIsWeb && Platform.isIOS && value) { + // // We're on iOS. Request notifications permissions if needed. + // final IOSFlutterLocalNotificationsPlugin? iosFlutterLocalNotificationsPlugin = + // FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); + + // await iosFlutterLocalNotificationsPlugin?.requestPermissions(alert: true, badge: true, sound: true); + + // // This setState has no body because async operations aren't allowed, + // // but its purpose is to update areIOSNotificationsAllowed. + // setState(() {}); + // } + // }, + // subtitle: enableInboxNotifications + // ? !kIsWeb && Platform.isAndroid && areAndroidNotificationsAllowed == true + // ? null + // : l10n.notificationsNotAllowed + // : null, + // highlightKey: settingToHighlight == LocalSettings.enableInboxNotifications ? settingToHighlightKey : null, + // ), + // ), const SliverToBoxAdapter(child: SizedBox(height: 16.0)), SliverToBoxAdapter( diff --git a/lib/thunder/bloc/thunder_bloc.dart b/lib/thunder/bloc/thunder_bloc.dart index 71a17c697..3865ccbf7 100644 --- a/lib/thunder/bloc/thunder_bloc.dart +++ b/lib/thunder/bloc/thunder_bloc.dart @@ -18,6 +18,7 @@ import 'package:thunder/core/enums/full_name.dart'; import 'package:thunder/core/enums/image_caching_mode.dart'; import 'package:thunder/core/enums/local_settings.dart'; import 'package:thunder/core/enums/nested_comment_indicator.dart'; +import 'package:thunder/core/enums/notification_type.dart'; import 'package:thunder/core/enums/post_body_view_type.dart'; import 'package:thunder/core/enums/swipe_action.dart'; import 'package:thunder/core/enums/theme_type.dart'; @@ -118,7 +119,7 @@ class ThunderBloc extends Bloc { bool markPostReadOnScroll = prefs.getBool(LocalSettings.markPostAsReadOnScroll.name) ?? false; bool showInAppUpdateNotification = prefs.getBool(LocalSettings.showInAppUpdateNotification.name) ?? false; bool showUpdateChangelogs = prefs.getBool(LocalSettings.showUpdateChangelogs.name) ?? true; - bool enableInboxNotifications = prefs.getBool(LocalSettings.enableInboxNotifications.name) ?? false; + NotificationType inboxNotificationType = NotificationType.values.byName(prefs.getString(LocalSettings.inboxNotificationType.name) ?? NotificationType.none.name) ?? NotificationType.none; String? appLanguageCode = prefs.getString(LocalSettings.appLanguageCode.name) ?? 'en'; FullNameSeparator userSeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.userFormat.name) ?? FullNameSeparator.at.name); bool userFullNameWeightUserName = prefs.getBool(LocalSettings.userFullNameWeightUserName.name) ?? false; @@ -273,7 +274,7 @@ class ThunderBloc extends Bloc { markPostReadOnScroll: markPostReadOnScroll, showInAppUpdateNotification: showInAppUpdateNotification, showUpdateChangelogs: showUpdateChangelogs, - enableInboxNotifications: enableInboxNotifications, + inboxNotificationType: inboxNotificationType, appLanguageCode: appLanguageCode, userSeparator: userSeparator, userFullNameWeightUserName: userFullNameWeightUserName, diff --git a/lib/thunder/bloc/thunder_state.dart b/lib/thunder/bloc/thunder_state.dart index c9531493d..d1c74461e 100644 --- a/lib/thunder/bloc/thunder_state.dart +++ b/lib/thunder/bloc/thunder_state.dart @@ -32,7 +32,7 @@ class ThunderState extends Equatable { this.disableFeedFab = false, this.showInAppUpdateNotification = false, this.showUpdateChangelogs = true, - this.enableInboxNotifications = false, + this.inboxNotificationType = NotificationType.none, this.scoreCounters = false, this.userSeparator = FullNameSeparator.at, this.userFullNameWeightUserName = false, @@ -181,7 +181,7 @@ class ThunderState extends Equatable { final bool disableFeedFab; final bool showInAppUpdateNotification; final bool showUpdateChangelogs; - final bool enableInboxNotifications; + final NotificationType inboxNotificationType; final String? appLanguageCode; final FullNameSeparator userSeparator; final bool userFullNameWeightUserName; @@ -338,7 +338,7 @@ class ThunderState extends Equatable { bool? markPostReadOnScroll, bool? showInAppUpdateNotification, bool? showUpdateChangelogs, - bool? enableInboxNotifications, + NotificationType? inboxNotificationType, bool? scoreCounters, FullNameSeparator? userSeparator, bool? userFullNameWeightUserName, @@ -489,7 +489,7 @@ class ThunderState extends Equatable { disableFeedFab: disableFeedFab, showInAppUpdateNotification: showInAppUpdateNotification ?? this.showInAppUpdateNotification, showUpdateChangelogs: showUpdateChangelogs ?? this.showUpdateChangelogs, - enableInboxNotifications: enableInboxNotifications ?? this.enableInboxNotifications, + inboxNotificationType: inboxNotificationType ?? this.inboxNotificationType, scoreCounters: scoreCounters ?? this.scoreCounters, appLanguageCode: appLanguageCode ?? this.appLanguageCode, userSeparator: userSeparator ?? this.userSeparator, @@ -647,7 +647,7 @@ class ThunderState extends Equatable { disableFeedFab, showInAppUpdateNotification, showUpdateChangelogs, - enableInboxNotifications, + inboxNotificationType, userSeparator, userFullNameWeightUserName, userFullNameWeightInstanceName, diff --git a/lib/utils/notifications.dart b/lib/utils/notifications.dart index 3185ecbfd..355a4de51 100644 --- a/lib/utils/notifications.dart +++ b/lib/utils/notifications.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; @@ -15,8 +16,10 @@ import 'package:thunder/account/models/account.dart'; import 'package:thunder/core/auth/helpers/fetch_account.dart'; import 'package:thunder/core/enums/full_name.dart'; import 'package:thunder/core/enums/local_settings.dart'; +import 'package:thunder/core/enums/notification_type.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/core/singletons/preferences.dart'; +import 'package:thunder/utils/global_context.dart'; import 'package:thunder/utils/instance.dart'; import 'package:unifiedpush/unifiedpush.dart'; @@ -26,72 +29,130 @@ const String repliesGroupKey = 'replies'; const String _lastPollTimeId = 'thunder_last_notifications_poll_time'; const int _repliesGroupSummaryId = 0; -// UnifiedPush variables -var instance = "myInstance"; -var endpoint = ""; -var registered = false; +/// Displays a push notification on Android +void showAndroidNotification({ + required int id, + String title = '', + String content = '', + required BigTextStyleInformation bigTextStyleInformation, + String payload = '', + String summaryText = '', +}) async { + final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + + // Configure Android-specific settings + final AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails( + _inboxMessagesChannelId, + _inboxMessagesChannelName, + styleInformation: bigTextStyleInformation, + groupKey: repliesGroupKey, + ); -/// Initialize iOS specific notification logic. This is only called when the app is running on iOS. -void initIOSPushNotificationLogic({required StreamController controller}) async { - // Fetch device token for APNs - final token = await Push.instance.token; + final NotificationDetails notificationDetails = NotificationDetails(android: androidNotificationDetails); - /// We need to send this device token along with the jwt so that the server can poll for new notifications and send them to this device. - debugPrint("Device token: $token"); + // Show the notification! + await flutterLocalNotificationsPlugin.show( + id, + title, + content, + notificationDetails, + payload: payload, + ); - // Handle new tokens generated from the device - Push.instance.onNewToken.listen((token) { - /// We need to send this device token along with the jwt so that the server can poll for new notifications and send them to this device. - debugPrint("Received new device token: $token"); - }); + // Create a summary notification for the group. + // Note that it's ok to create this for every message, because it has a fixed ID, + // so it will just get 'updated'. + final InboxStyleInformation inboxStyleInformationSummary = InboxStyleInformation( + [], + contentTitle: '', + summaryText: summaryText, + ); - // Handle notification launching app from terminated state - Push.instance.notificationTapWhichLaunchedAppFromTerminated.then((data) { - if (data == null) return; + final AndroidNotificationDetails androidNotificationDetailsSummary = AndroidNotificationDetails( + _inboxMessagesChannelId, + _inboxMessagesChannelName, + styleInformation: inboxStyleInformationSummary, + groupKey: repliesGroupKey, + setAsGroupSummary: true, + ); - debugPrint('Notification was tapped notificationTapWhichLaunchedAppFromTerminated: Data: $data \n'); - if (data.containsKey(repliesGroupKey)) { - controller.add(NotificationResponse(payload: data[repliesGroupKey] as String, notificationResponseType: NotificationResponseType.selectedNotification)); - } - }); + final NotificationDetails notificationDetailsSummary = NotificationDetails(android: androidNotificationDetailsSummary); - /// Handle notification taps. This triggers when the user taps on a notification when the app is on the foreground or background. - Push.instance.onNotificationTap.listen((data) { - debugPrint('Notification was tapped onNotificationTap: Data: $data \n'); - - if (data.containsKey(repliesGroupKey)) { - controller.add(NotificationResponse(payload: data[repliesGroupKey] as String, notificationResponseType: NotificationResponseType.selectedNotification)); - } - }); + // Send the summary message! + await flutterLocalNotificationsPlugin.show( + _repliesGroupSummaryId, + '', + '', + notificationDetailsSummary, + payload: repliesGroupKey, + ); } -void onUnregistered(String _instance) { - if (_instance != instance) return; - registered = false; - debugPrint("unregistered"); +Future initPushNotificationLogic({required StreamController controller}) async { + if (Platform.isAndroid) { + initAndroidPushNotificationLogic(controller: controller); + } + + if (Platform.isIOS) { + initIOSPushNotificationLogic(controller: controller); + } } /// Initialize Android specific notification logic. This is only called when the app is running on Android. void initAndroidPushNotificationLogic({required StreamController controller}) async { - // Load up preferences SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; + NotificationType notificationType = NotificationType.values.byName(prefs.getString(LocalSettings.inboxNotificationType.name) ?? NotificationType.none.name); - bool useUnifiedPush = true; + // Return if we don't have the expected types + if (!(notificationType != NotificationType.local || notificationType != NotificationType.unifiedPush)) return; - if (useUnifiedPush) { + if (notificationType == NotificationType.unifiedPush) { UnifiedPush.initialize( - onNewEndpoint: (String _endpoint, String _instance) { - if (_instance != instance) return; - registered = true; - endpoint = _endpoint; - debugPrint(endpoint); + onNewEndpoint: (String endpoint, String instance) { + debugPrint("Connected to instance: $instance @ $endpoint"); + }, + onRegistrationFailed: (String instance) { + debugPrint("UnifiedPush registration failed for $instance"); + }, + onUnregistered: (String instance) { + debugPrint("UnifiedPush unregistered from $instance"); + }, + onMessage: (Uint8List message, String instance) { + Map data = jsonDecode(utf8.decode(message)); + Map metadata = data['metadata']; + + final FullNameSeparator userSeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.userFormat.name) ?? FullNameSeparator.at.name); + final FullNameSeparator communitySeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.communityFormat.name) ?? FullNameSeparator.dot.name); + + final BigTextStyleInformation bigTextStyleInformation = BigTextStyleInformation( + '${metadata['post']['title']} · ${generateCommunityFullName(null, metadata['community']['name'], fetchInstanceNameFromUrl(metadata['community']['instance']), communitySeparator: communitySeparator)}\n$message', + contentTitle: generateUserFullName(null, metadata['creator']['name'], fetchInstanceNameFromUrl(metadata['creator']['instance']), userSeparator: userSeparator), + summaryText: generateUserFullName(null, metadata['account']['name'], metadata['account']['instance'], userSeparator: userSeparator), + htmlFormatBigText: true, + ); + + final String summaryText = generateUserFullName( + null, + metadata['account']['name'], + metadata['account']['instance'], + userSeparator: userSeparator, + ); + + showAndroidNotification( + id: 1, + title: data['title'] ?? "", + content: data['message'] ?? "", + payload: '$repliesGroupKey-${data['metadata']['id']}', + summaryText: summaryText, + bigTextStyleInformation: bigTextStyleInformation, + ); }, - onRegistrationFailed: onUnregistered, - onUnregistered: onUnregistered, - onMessage: (Uint8List message, String instance) {}, ); - } else { - final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + + // Register Thunder with UnifiedPush + if (GlobalContext.context.mounted) UnifiedPush.registerAppWithDialog(GlobalContext.context, 'Thunder', []); + } else if (notificationType == NotificationType.local) { + FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); // Initialize the Android-specific settings, using the splash asset as the notification icon. const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('splash'); @@ -105,8 +166,8 @@ void initAndroidPushNotificationLogic({required StreamController initPushNotificationLogic({required StreamController controller}) async { - if (Platform.isAndroid) { - initAndroidPushNotificationLogic(controller: controller); - } +/// Initialize iOS specific notification logic. This is only called when the app is running on iOS. +void initIOSPushNotificationLogic({required StreamController controller}) async { + // Fetch device token for APNs + String? token = await Push.instance.token; + if (token == null) return; - if (Platform.isIOS) { - initIOSPushNotificationLogic(controller: controller); - } + /// We need to send this device token along with the jwt so that the server can poll for new notifications and send them to this device. + debugPrint("Device token: $token"); + + // Handle new tokens generated from the device + Push.instance.onNewToken.listen((token) { + /// We need to send this device token along with the jwt so that the server can poll for new notifications and send them to this device. + debugPrint("Received new device token: $token"); + }); + + // Handle notification launching app from terminated state + Push.instance.notificationTapWhichLaunchedAppFromTerminated.then((data) { + if (data == null) return; + + debugPrint('Notification was tapped notificationTapWhichLaunchedAppFromTerminated: Data: $data \n'); + if (data.containsKey(repliesGroupKey)) { + controller.add(NotificationResponse(payload: data[repliesGroupKey] as String, notificationResponseType: NotificationResponseType.selectedNotification)); + } + }); + + /// Handle notification taps. This triggers when the user taps on a notification when the app is on the foreground or background. + Push.instance.onNotificationTap.listen((data) { + debugPrint('Notification was tapped onNotificationTap: Data: $data \n'); + + if (data.containsKey(repliesGroupKey)) { + controller.add(NotificationResponse(payload: data[repliesGroupKey] as String, notificationResponseType: NotificationResponseType.selectedNotification)); + } + }); } // ---------------- ANDROID LOCAL NOTIFICATIONS FETCH LOGIC ---------------- // @@ -140,28 +226,24 @@ Future initPushNotificationLogic({required StreamController pollRepliesAndShowNotifications() async { // This print statement is here for the sake of verifying that background checks only happen when they're supposed to. - // If we see this line outputted when notifications are disabled, then something is wrong - // with our configuration of background_fetch. + // If we see this line outputted when notifications are disabled, then something is wrong with our configuration of background_fetch. debugPrint('Thunder - Background fetch - Running notification poll'); + final Account? account = await fetchActiveProfileAccount(); + if (account == null) return; + final SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; final FullNameSeparator userSeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.userFormat.name) ?? FullNameSeparator.at.name); final FullNameSeparator communitySeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.communityFormat.name) ?? FullNameSeparator.dot.name); - // We shouldn't even come here if the setting is disabled, but just in case, exit. - if (prefs.getBool(LocalSettings.enableInboxNotifications.name) != true) return; - - final Account? account = await fetchActiveProfileAccount(); - if (account == null) return; - final DateTime lastPollTime = DateTime.tryParse(prefs.getString(_lastPollTimeId) ?? '') ?? DateTime.now(); - // Iterate through inbox replies - // In the future, this could ALSO iterate among all saved accounts. + // Iterate through inbox replies. This only fetches the current active account. + // TODO: Iterate through all saved accounts. GetRepliesResponse getRepliesResponse = await LemmyClient.instance.lemmyApiV3.run( GetReplies( auth: account.jwt!, - unreadOnly: true, + unreadOnly: false, limit: 50, // Max allowed by API sort: CommentSortType.old, page: 1, @@ -171,49 +253,26 @@ Future pollRepliesAndShowNotifications() async { // Only handle messages that have arrived since the last time we polled final Iterable newReplies = getRepliesResponse.replies.where((CommentReplyView commentReplyView) => commentReplyView.comment.published.isAfter(lastPollTime)); - // For each message, generate a notification. - // On Android, put them in the same group. + // For each message, generate a notification. On Android, put them in the same group. for (final CommentReplyView commentReplyView in newReplies) { // Format the comment body in a couple ways final String htmlComment = markdownToHtml(commentReplyView.comment.content); final String plaintextComment = parse(parse(htmlComment).body?.text).documentElement?.text ?? commentReplyView.comment.content; + final String title = generateUserFullName(null, commentReplyView.creator.name, fetchInstanceNameFromUrl(commentReplyView.creator.actorId), userSeparator: userSeparator); - final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - // Configure Android-specific settings final BigTextStyleInformation bigTextStyleInformation = BigTextStyleInformation( '${commentReplyView.post.name} · ${generateCommunityFullName(null, commentReplyView.community.name, fetchInstanceNameFromUrl(commentReplyView.community.actorId), communitySeparator: communitySeparator)}\n$htmlComment', contentTitle: generateUserFullName(null, commentReplyView.creator.name, fetchInstanceNameFromUrl(commentReplyView.creator.actorId), userSeparator: userSeparator), summaryText: generateUserFullName(null, account.username, account.instance, userSeparator: userSeparator), htmlFormatBigText: true, ); - final AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails( - _inboxMessagesChannelId, - _inboxMessagesChannelName, - styleInformation: bigTextStyleInformation, - groupKey: repliesGroupKey, - ); - final NotificationDetails notificationDetails = NotificationDetails(android: androidNotificationDetails); - - // Show the notification! - await flutterLocalNotificationsPlugin.show( - // This is the notification ID, which should be unique. - // In the future it might need to incorporate user/instance id - // to avoid comment id collisions. - commentReplyView.comment.id, - // Title (username of sender) - generateUserFullName(null, commentReplyView.creator.name, fetchInstanceNameFromUrl(commentReplyView.creator.actorId), userSeparator: userSeparator), - // Body (body of comment) - plaintextComment, - notificationDetails, - payload: '$repliesGroupKey-${commentReplyView.commentReply.id}', - ); - // Create a summary notification for the group. - // Note that it's ok to create this for every message, because it has a fixed ID, - // so it will just get 'updated'. - final InboxStyleInformation inboxStyleInformationSummary = InboxStyleInformation( - [], - contentTitle: '', + showAndroidNotification( + id: commentReplyView.comment.id, + title: title, + content: plaintextComment, + bigTextStyleInformation: bigTextStyleInformation, + payload: '$repliesGroupKey-${commentReplyView.commentReply.id}', summaryText: generateUserFullName( null, account.username, @@ -221,25 +280,6 @@ Future pollRepliesAndShowNotifications() async { userSeparator: userSeparator, ), ); - - final AndroidNotificationDetails androidNotificationDetailsSummary = AndroidNotificationDetails( - _inboxMessagesChannelId, - _inboxMessagesChannelName, - styleInformation: inboxStyleInformationSummary, - groupKey: repliesGroupKey, - setAsGroupSummary: true, - ); - - final NotificationDetails notificationDetailsSummary = NotificationDetails(android: androidNotificationDetailsSummary); - - // Send the summary message! - await flutterLocalNotificationsPlugin.show( - _repliesGroupSummaryId, - '', - '', - notificationDetailsSummary, - payload: repliesGroupKey, - ); } // Save our poll time @@ -255,7 +295,6 @@ void backgroundFetchHeadlessTask(HeadlessTask task) async { BackgroundFetch.finish(task.taskId); return; } - // Run the poll! await pollRepliesAndShowNotifications(); BackgroundFetch.finish(task.taskId); } @@ -264,7 +303,7 @@ void backgroundFetchHeadlessTask(HeadlessTask task) async { Future initBackgroundFetch() async { await BackgroundFetch.configure( BackgroundFetchConfig( - minimumFetchInterval: 1, + minimumFetchInterval: 15, stopOnTerminate: false, startOnBoot: true, enableHeadless: true, @@ -274,11 +313,10 @@ Future initBackgroundFetch() async { requiresCharging: false, requiresDeviceIdle: false, // Uncomment this line (and set the minimumFetchInterval to 1) for quicker testing. - forceAlarmManager: true, + // forceAlarmManager: true, ), // This is the callback that handles background fetching while the app is running. (String taskId) async { - // Run the poll! await pollRepliesAndShowNotifications(); BackgroundFetch.finish(taskId); }, diff --git a/lib/utils/preferences.dart b/lib/utils/preferences.dart index 2082e7e2a..fb5a645fb 100644 --- a/lib/utils/preferences.dart +++ b/lib/utils/preferences.dart @@ -1,6 +1,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:thunder/core/enums/browser_mode.dart'; import 'package:thunder/core/enums/local_settings.dart'; +import 'package:thunder/core/enums/notification_type.dart'; import 'package:thunder/core/singletons/preferences.dart'; Future performSharedPreferencesMigration() async { @@ -26,4 +27,11 @@ Future performSharedPreferencesMigration() async { await prefs.remove(LocalSettings.commentUseColorizedUsername.name); await prefs.setBool(LocalSettings.userFullNameColorizeUserName.name, legacyCommentUseColorizedUsername); } + + // Migrate the enableInboxNotifications setting, if found. + bool? legacyEnableInboxNotifications = prefs.getBool(LocalSettings.enableInboxNotifications.name); + if (legacyEnableInboxNotifications != null) { + await prefs.remove(LocalSettings.enableInboxNotifications.name); + await prefs.setString(LocalSettings.inboxNotificationType.name, legacyEnableInboxNotifications ? NotificationType.local.name : NotificationType.none.name); + } } diff --git a/pubspec.lock b/pubspec.lock index 6d8fa47cc..a48698883 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1374,8 +1374,8 @@ packages: dependency: "direct overridden" description: path: push_android - ref: "722c5e8541e1e5c91c56743b94d07f51f26fd2c5" - resolved-ref: "722c5e8541e1e5c91c56743b94d07f51f26fd2c5" + ref: "70269bd08a06c4f34f6f192a2d46127ae48479a5" + resolved-ref: "70269bd08a06c4f34f6f192a2d46127ae48479a5" url: "https://github.com/hjiangsu/push.git" source: git version: "0.5.0" diff --git a/pubspec.yaml b/pubspec.yaml index a899b07e9..a066b99f6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -128,7 +128,7 @@ dependency_overrides: push_android: git: url: https://github.com/hjiangsu/push.git - ref: 722c5e8541e1e5c91c56743b94d07f51f26fd2c5 + ref: 70269bd08a06c4f34f6f192a2d46127ae48479a5 path: push_android # The following section is specific to Flutter packages. From 15440bce90a0730ae7ba2bc946a3b46e05951c89 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Mon, 25 Mar 2024 21:44:57 -0700 Subject: [PATCH 05/18] adjusted settings logic, added notification type to setting --- lib/core/enums/notification_type.dart | 21 ++- lib/l10n/app_en.arb | 4 +- lib/settings/pages/general_settings_page.dart | 140 +++++------------- lib/settings/widgets/list_option.dart | 10 +- 4 files changed, 67 insertions(+), 108 deletions(-) diff --git a/lib/core/enums/notification_type.dart b/lib/core/enums/notification_type.dart index 421265d0b..1a1f3a52a 100644 --- a/lib/core/enums/notification_type.dart +++ b/lib/core/enums/notification_type.dart @@ -1 +1,20 @@ -enum NotificationType { none, local, unifiedPush, apn } +enum NotificationType { + none, + local, + unifiedPush, + apn; + + @override + String toString() { + switch (this) { + case NotificationType.none: + return 'None'; + case NotificationType.local: + return 'Local Notifications'; + case NotificationType.unifiedPush: + return 'Unified Push Notifications'; + case NotificationType.apn: + return 'Apple Push Notification Service'; + } + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 09c76b08c..7ab75dad4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -513,7 +513,7 @@ "@enableFloatingButtonOnPosts": { "description": "Setting for enable floating button on posts" }, - "enableInboxNotifications": "Enable Inbox Notifications (Experimental)", + "enableInboxNotifications": "Enable Inbox Notifications", "@enableInboxNotifications": { "description": "Setting name for inbox notifications" }, @@ -1003,7 +1003,7 @@ "@notificationsNotAllowed": { "description": "Description for when notifications are now allowed for app" }, - "notificationsWarningDialog": "Notifications are an experimental feature which may not function correctly on all devices.\n\n· Checks will occur every ~15 minutes and will consume additional battery.\n\n· Disable battery optimizations for a higher likelihood of successful notifications.\n\nSee the following page for more information.", + "notificationsWarningDialog": "Notifications are an **experimental feature** which may not function correctly on all devices.\n\n - Checks will occur every ~15 minutes and will consume additional battery.\n\n - Disable battery optimizations for a higher likelihood of successful notifications.\n\n See the following page for more information.", "@notificationsWarningDialog": { "description": "The content of the warning dialog for the notifications feature" }, diff --git a/lib/settings/pages/general_settings_page.dart b/lib/settings/pages/general_settings_page.dart index 1c1734155..ff9208d59 100644 --- a/lib/settings/pages/general_settings_page.dart +++ b/lib/settings/pages/general_settings_page.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:io'; + import 'package:flutter/material.dart'; + import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:go_router/go_router.dart'; @@ -10,7 +12,6 @@ import 'package:android_intent_plus/android_intent.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:push/push.dart'; import 'package:thunder/core/enums/browser_mode.dart'; import 'package:thunder/core/enums/full_name.dart'; import 'package:thunder/core/enums/image_caching_mode.dart'; @@ -19,12 +20,13 @@ import 'package:thunder/core/enums/local_settings.dart'; import 'package:thunder/core/enums/notification_type.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/core/singletons/preferences.dart'; -import 'package:thunder/main.dart'; import 'package:thunder/settings/widgets/list_option.dart'; import 'package:thunder/settings/widgets/settings_list_tile.dart'; import 'package:thunder/settings/widgets/toggle_option.dart'; import 'package:thunder/shared/comment_sort_picker.dart'; +import 'package:thunder/shared/common_markdown_body.dart'; import 'package:thunder/shared/dialogs.dart'; +import 'package:thunder/shared/snackbar.dart'; import 'package:thunder/shared/sort_picker.dart'; import 'package:thunder/thunder/bloc/thunder_bloc.dart'; import 'package:thunder/utils/bottom_sheet_list_picker.dart'; @@ -32,7 +34,6 @@ import 'package:thunder/utils/constants.dart'; import 'package:thunder/utils/language/language.dart'; import 'package:thunder/utils/links.dart'; import 'package:thunder/utils/notifications.dart'; -import 'package:unifiedpush/unifiedpush.dart'; class GeneralSettingsPage extends StatefulWidget { final LocalSettings? settingToHighlight; @@ -89,12 +90,6 @@ class _GeneralSettingsPageState extends State with SingleTi /// When enabled, system-level notifications will be displayed for new inbox messages NotificationType inboxNotificationType = NotificationType.none; - /// Not a setting, but tracks whether Android is allowing Thunder to send notifications - bool? areAndroidNotificationsAllowed = false; - - /// Not a setting, but tracks whether iOS is allowing Thunder to send notifications - UNNotificationSettings? areIOSNotificationsAllowed; - /// When enabled, authors and community names will be tappable when in compact view bool tappableAuthorCommunity = false; @@ -274,12 +269,6 @@ class _GeneralSettingsPageState extends State with SingleTi void _initPreferences() async { final prefs = (await UserPreferences.instance).sharedPreferences; - if (Platform.isIOS) { - Push.instance.getNotificationSettings().then((settings) => areIOSNotificationsAllowed = settings); - } else if (Platform.isAndroid) { - Push.instance.areNotificationsEnabled().then((areNotificationsEnabled) => areAndroidNotificationsAllowed = areNotificationsEnabled); - } - setState(() { // Default Sorts and Listing try { @@ -327,20 +316,11 @@ class _GeneralSettingsPageState extends State with SingleTi }); } - Future checkAndroidNotificationStatus() async { - // Check whether Android is currently allowing Thunder to send notifications - final AndroidFlutterLocalNotificationsPlugin? androidFlutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); - final bool? areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.areNotificationsEnabled(); - setState(() => this.areAndroidNotificationsAllowed = areAndroidNotificationsAllowed); - } - @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) async { _initPreferences(); - await checkAndroidNotificationStatus(); if (widget.settingToHighlight != null) { setState(() => settingToHighlight = widget.settingToHighlight); @@ -1002,8 +982,9 @@ class _GeneralSettingsPageState extends State with SingleTi SliverToBoxAdapter( child: ListOption( description: l10n.enableInboxNotifications, + subtitle: inboxNotificationType.toString(), value: const ListPickerItem(payload: -1), - icon: Icons.notifications_on_rounded, + icon: inboxNotificationType == NotificationType.none ? Icons.notifications_off_rounded : Icons.notifications_on_rounded, highlightKey: settingToHighlight == LocalSettings.inboxNotificationType ? settingToHighlightKey : null, customListPicker: StatefulBuilder( builder: (context, setState) { @@ -1011,7 +992,9 @@ class _GeneralSettingsPageState extends State with SingleTi title: "Push Notifications", heading: const Align( alignment: Alignment.centerLeft, - child: Text("Using this feature requires the server to store your JWT token in order to poll for notifications. A restart of the app is required for this to take effect."), + child: CommonMarkdownBody( + body: + "If enabled, Thunder will send your JWT token(s) to the server in order to poll for new notifications. \n\n **NOTE:** This will not take effect until the next time the app is launched."), ), previouslySelected: inboxNotificationType, items: Platform.isAndroid @@ -1020,21 +1003,18 @@ class _GeneralSettingsPageState extends State with SingleTi icon: Icons.notifications_off_rounded, label: "None", payload: NotificationType.none, - capitalizeLabel: true, ), const ListPickerItem( icon: Icons.notifications_rounded, label: "Use Local Notifications (Experimental)", - subtitle: "Periodically checks for notifications in the background", + subtitle: "Periodically checks for notifications in the background. Does not send your JWT token(s) to the server.", payload: NotificationType.local, - capitalizeLabel: true, ), const ListPickerItem( icon: Icons.notifications_active_rounded, label: "Use UnifiedPush Notifications", - subtitle: "Requires a compatible UnifiedPush app", + subtitle: "Requires a compatible app", payload: NotificationType.unifiedPush, - capitalizeLabel: true, ), ] : [ @@ -1042,26 +1022,25 @@ class _GeneralSettingsPageState extends State with SingleTi icon: Icons.notifications_off_rounded, label: "Disable Push Notifications", payload: NotificationType.none, - capitalizeLabel: true, ), const ListPickerItem( icon: Icons.notifications_active_rounded, - label: "Use UnifiedPush Notifications", - subtitle: "Requires a compatible UnifiedPush app", + label: "Use APNs Notifications", + subtitle: "Uses Apple's Push Notification service", payload: NotificationType.apn, - capitalizeLabel: true, ), ], onSelect: (ListPickerItem notificationType) async { if (notificationType.payload == NotificationType.local) { bool res = false; + await showThunderDialog( context: context, title: l10n.warning, contentWidgetBuilder: (_) => Column( mainAxisSize: MainAxisSize.min, children: [ - Text(l10n.notificationsWarningDialog), + CommonMarkdownBody(body: l10n.notificationsWarningDialog), const SizedBox(height: 10), Align( alignment: Alignment.centerLeft, @@ -1084,29 +1063,41 @@ class _GeneralSettingsPageState extends State with SingleTi onSecondaryButtonPressed: (dialogContext) => dialogContext.pop(), ); - // The user chose not to enable the feature - if (!res) return; + if (!res) { + // The user chose not to enable the feature. Disable any existing background fetches. + return disableBackgroundFetch(); + } // Enable local notifications initBackgroundFetch(); initHeadlessBackgroundFetch(); } else if (notificationType.payload == NotificationType.unifiedPush) { - // UnifiedPush.registerAppWithDialog(context, instance, []); - - // Disable local notifications + // Disable local notifications if present. disableBackgroundFetch(); } if (notificationType.payload == NotificationType.local || notificationType.payload == NotificationType.unifiedPush) { - // We're on Android. Request notifications permissions if needed. - // This is a no-op if on SDK version < 33 - final AndroidFlutterLocalNotificationsPlugin? androidFlutterLocalNotificationsPlugin = + // We're on Android. Request notifications permissions if needed. This is a no-op if on SDK version < 33 + AndroidFlutterLocalNotificationsPlugin? androidFlutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); - areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.areNotificationsEnabled(); + bool? areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.areNotificationsEnabled(); + if (areAndroidNotificationsAllowed != true) { areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.requestNotificationsPermission(); + if (areAndroidNotificationsAllowed != true) return showSnackbar('Failed to request Android notifications permissions.'); } + } else if (notificationType.payload == NotificationType.apn) { + // We're on iOS. Request notifications permissions if needed. + IOSFlutterLocalNotificationsPlugin? iosFlutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); + + NotificationsEnabledOptions? notificationsEnabledOptions = await iosFlutterLocalNotificationsPlugin?.checkPermissions(); + + if (notificationsEnabledOptions?.isEnabled != true) { + bool? isEnabled = await iosFlutterLocalNotificationsPlugin?.requestPermissions(alert: true, badge: true, sound: true); + if (isEnabled != true) return showSnackbar('Failed to request iOS notifications permissions.'); + } else {} } setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); @@ -1116,65 +1107,6 @@ class _GeneralSettingsPageState extends State with SingleTi ), ), ), - // if (!kIsWeb && Platform.isAndroid || Platform.isIOS) - // SliverToBoxAdapter( - // child: ToggleOption( - // description: l10n.enableInboxNotifications, - // value: enableInboxNotifications, - // iconEnabled: Icons.notifications_on_rounded, - // iconDisabled: Icons.notifications_off_rounded, - // onToggle: (bool value) async { - // // Show a warning message about the experimental nature of this feature. - // // This message is specific to Android. - - // setPreferences(LocalSettings.enableInboxNotifications, value); - - // if (!kIsWeb && Platform.isAndroid && value) { - // // We're on Android. Request notifications permissions if needed. - // // This is a no-op if on SDK version < 33 - // final AndroidFlutterLocalNotificationsPlugin? androidFlutterLocalNotificationsPlugin = - // FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); - - // areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.areNotificationsEnabled(); - // if (areAndroidNotificationsAllowed != true) { - // areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.requestNotificationsPermission(); - // } - - // if (value) { - // // Ensure that background fetching is enabled. - // initBackgroundFetch(); - // initHeadlessBackgroundFetch(); - // } else { - // // Ensure that background fetching is disabled. - // disableBackgroundFetch(); - // } - - // // This setState has no body because async operations aren't allowed, - // // but its purpose is to update areAndroidNotificationsAllowed. - // setState(() {}); - // } - - // if (!kIsWeb && Platform.isIOS && value) { - // // We're on iOS. Request notifications permissions if needed. - // final IOSFlutterLocalNotificationsPlugin? iosFlutterLocalNotificationsPlugin = - // FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); - - // await iosFlutterLocalNotificationsPlugin?.requestPermissions(alert: true, badge: true, sound: true); - - // // This setState has no body because async operations aren't allowed, - // // but its purpose is to update areIOSNotificationsAllowed. - // setState(() {}); - // } - // }, - // subtitle: enableInboxNotifications - // ? !kIsWeb && Platform.isAndroid && areAndroidNotificationsAllowed == true - // ? null - // : l10n.notificationsNotAllowed - // : null, - // highlightKey: settingToHighlight == LocalSettings.enableInboxNotifications ? settingToHighlightKey : null, - // ), - // ), - const SliverToBoxAdapter(child: SizedBox(height: 16.0)), SliverToBoxAdapter( child: Padding( diff --git a/lib/settings/widgets/list_option.dart b/lib/settings/widgets/list_option.dart index 49d91db1e..9ede80f46 100644 --- a/lib/settings/widgets/list_option.dart +++ b/lib/settings/widgets/list_option.dart @@ -11,6 +11,7 @@ class ListOption extends StatelessWidget { // General final String description; + final String? subtitle; final Widget? bottomSheetHeading; final ListPickerItem value; final List> options; @@ -32,6 +33,7 @@ class ListOption extends StatelessWidget { const ListOption({ super.key, this.description = '', + this.subtitle, this.bottomSheetHeading, required this.value, this.options = const [], @@ -88,7 +90,13 @@ class ListOption extends StatelessWidget { children: [ Icon(icon), const SizedBox(width: 8.0), - Text(description, style: theme.textTheme.bodyMedium), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(description, style: theme.textTheme.bodyMedium), + if (subtitle != null) Text(subtitle!, style: theme.textTheme.bodySmall?.copyWith(color: theme.textTheme.bodySmall?.color?.withOpacity(0.8))), + ], + ), ], ), Row( From 0a6418b88b872d874d10cfa0750f39013bf849f0 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Wed, 27 Mar 2024 06:04:33 -0700 Subject: [PATCH 06/18] moved notifications to separate subdirectory, added stubs for connecting to server --- lib/main.dart | 2 +- lib/settings/pages/general_settings_page.dart | 101 +++++++++++------- lib/shared/picker_item.dart | 4 +- .../notifications_cubit.dart | 2 +- lib/utils/constants.dart | 2 + .../notifications/notification_server.dart | 54 ++++++++++ .../{ => notifications}/notifications.dart | 13 +-- 7 files changed, 126 insertions(+), 52 deletions(-) create mode 100644 lib/utils/notifications/notification_server.dart rename lib/utils/{ => notifications}/notifications.dart (96%) diff --git a/lib/main.dart b/lib/main.dart index 586c49bd3..82fb9a5aa 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,7 +30,7 @@ import 'package:thunder/user/bloc/user_bloc.dart'; import 'package:thunder/utils/cache.dart'; import 'package:thunder/utils/global_context.dart'; import 'package:flutter/foundation.dart'; -import 'package:thunder/utils/notifications.dart'; +import 'package:thunder/utils/notifications/notifications.dart'; import 'package:thunder/utils/preferences.dart'; import 'package:thunder/account/bloc/account_bloc.dart'; import 'package:thunder/community/bloc/community_bloc.dart'; diff --git a/lib/settings/pages/general_settings_page.dart b/lib/settings/pages/general_settings_page.dart index ff9208d59..561053609 100644 --- a/lib/settings/pages/general_settings_page.dart +++ b/lib/settings/pages/general_settings_page.dart @@ -33,7 +33,8 @@ import 'package:thunder/utils/bottom_sheet_list_picker.dart'; import 'package:thunder/utils/constants.dart'; import 'package:thunder/utils/language/language.dart'; import 'package:thunder/utils/links.dart'; -import 'package:thunder/utils/notifications.dart'; +import 'package:thunder/utils/notifications/notifications.dart'; +import 'package:unifiedpush/unifiedpush.dart'; class GeneralSettingsPage extends StatefulWidget { final LocalSettings? settingToHighlight; @@ -1007,7 +1008,7 @@ class _GeneralSettingsPageState extends State with SingleTi const ListPickerItem( icon: Icons.notifications_rounded, label: "Use Local Notifications (Experimental)", - subtitle: "Periodically checks for notifications in the background. Does not send your JWT token(s) to the server.", + subtitle: "Periodically checks for notifications in the background", payload: NotificationType.local, ), const ListPickerItem( @@ -1031,6 +1032,23 @@ class _GeneralSettingsPageState extends State with SingleTi ), ], onSelect: (ListPickerItem notificationType) async { + if (notificationType.payload == inboxNotificationType) return; + + // Disable all notifications since the option has changed + if (Platform.isAndroid) { + disableBackgroundFetch(); + UnifiedPush.unregister(); + } else if (Platform.isIOS) { + // TODO: Disable APNs + } + + // If disabled, do nothing + if (notificationType.payload == NotificationType.none) { + setPreferences(LocalSettings.inboxNotificationType, notificationType.payload.name); + return; + } + + // If using local notifications, show a warning if (notificationType.payload == NotificationType.local) { bool res = false; @@ -1041,7 +1059,7 @@ class _GeneralSettingsPageState extends State with SingleTi mainAxisSize: MainAxisSize.min, children: [ CommonMarkdownBody(body: l10n.notificationsWarningDialog), - const SizedBox(height: 10), + const SizedBox(height: 5), Align( alignment: Alignment.centerLeft, child: GestureDetector( @@ -1063,44 +1081,51 @@ class _GeneralSettingsPageState extends State with SingleTi onSecondaryButtonPressed: (dialogContext) => dialogContext.pop(), ); - if (!res) { - // The user chose not to enable the feature. Disable any existing background fetches. - return disableBackgroundFetch(); - } - - // Enable local notifications - initBackgroundFetch(); - initHeadlessBackgroundFetch(); - } else if (notificationType.payload == NotificationType.unifiedPush) { - // Disable local notifications if present. - disableBackgroundFetch(); + if (!res) return; } - if (notificationType.payload == NotificationType.local || notificationType.payload == NotificationType.unifiedPush) { - // We're on Android. Request notifications permissions if needed. This is a no-op if on SDK version < 33 - AndroidFlutterLocalNotificationsPlugin? androidFlutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); - - bool? areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.areNotificationsEnabled(); - - if (areAndroidNotificationsAllowed != true) { - areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.requestNotificationsPermission(); - if (areAndroidNotificationsAllowed != true) return showSnackbar('Failed to request Android notifications permissions.'); - } - } else if (notificationType.payload == NotificationType.apn) { - // We're on iOS. Request notifications permissions if needed. - IOSFlutterLocalNotificationsPlugin? iosFlutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); - - NotificationsEnabledOptions? notificationsEnabledOptions = await iosFlutterLocalNotificationsPlugin?.checkPermissions(); - - if (notificationsEnabledOptions?.isEnabled != true) { - bool? isEnabled = await iosFlutterLocalNotificationsPlugin?.requestPermissions(alert: true, badge: true, sound: true); - if (isEnabled != true) return showSnackbar('Failed to request iOS notifications permissions.'); - } else {} + // Check notifications permissions and enable them if needed + switch (notificationType.payload) { + case NotificationType.local: + case NotificationType.unifiedPush: + // We're on Android. Request notifications permissions if needed. This is a no-op if on SDK version < 33 + AndroidFlutterLocalNotificationsPlugin? androidFlutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); + + bool? areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.areNotificationsEnabled(); + + if (areAndroidNotificationsAllowed != true) { + areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.requestNotificationsPermission(); + if (areAndroidNotificationsAllowed != true) return showSnackbar('Failed to request Android notifications permissions.'); + } + + // Permissions have been granted, so we can enable notifications + if (notificationType.payload == NotificationType.local) { + initBackgroundFetch(); + initHeadlessBackgroundFetch(); + setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); + } else if (notificationType.payload == NotificationType.unifiedPush) { + // TODO: set up a way to enable UnifiedPush without app restart + setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); + } + break; + case NotificationType.apn: + // We're on iOS. Request notifications permissions if needed. + IOSFlutterLocalNotificationsPlugin? iosFlutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); + + NotificationsEnabledOptions? notificationsEnabledOptions = await iosFlutterLocalNotificationsPlugin?.checkPermissions(); + + if (notificationsEnabledOptions?.isEnabled != true) { + bool? isEnabled = await iosFlutterLocalNotificationsPlugin?.requestPermissions(alert: true, badge: true, sound: true); + if (isEnabled != true) return showSnackbar('Failed to request iOS notifications permissions.'); + // TODO: set up a way to enable APNs without app restart + setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); + } + break; + default: + break; } - - setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); }, ); }, diff --git a/lib/shared/picker_item.dart b/lib/shared/picker_item.dart index ae7dc7b5c..9f061d055 100644 --- a/lib/shared/picker_item.dart +++ b/lib/shared/picker_item.dart @@ -50,8 +50,8 @@ class PickerItem extends StatelessWidget { style: (textTheme?.bodyMedium ?? theme.textTheme.bodyMedium)?.copyWith( color: (textTheme?.bodyMedium ?? theme.textTheme.bodyMedium)?.color?.withOpacity(0.5), ), - softWrap: false, - overflow: TextOverflow.fade, + // softWrap: false, + // overflow: TextOverflow.fade, ) : null, leading: icon != null ? Icon(icon) : this.leading, diff --git a/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart b/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart index 3965e7440..cef8cf4bd 100644 --- a/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart +++ b/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:thunder/utils/notifications.dart'; +import 'package:thunder/utils/notifications/notifications.dart'; part 'notifications_state.dart'; diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 5894e2498..99dcd8ea6 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -52,3 +52,5 @@ const String SETTINGS_DEBUG_PAGE = '/settings/debug'; const String SETTINGS_APPEARANCE_POSTS_PAGE = '/settings/appearance/posts'; const String SETTINGS_APPEARANCE_COMMENTS_PAGE = '/settings/appearance/comments'; const String SETTINGS_APPEARANCE_THEMES_PAGE = '/settings/appearance/themes'; + +const String THUNDER_SERVER_URL = 'https://thunderapp.dev'; diff --git a/lib/utils/notifications/notification_server.dart b/lib/utils/notifications/notification_server.dart new file mode 100644 index 000000000..1001acd0d --- /dev/null +++ b/lib/utils/notifications/notification_server.dart @@ -0,0 +1,54 @@ +import 'package:http/http.dart' as http; +import 'package:thunder/account/models/account.dart'; + +import 'package:thunder/core/enums/notification_type.dart'; +import 'package:thunder/utils/constants.dart'; + +String notificationServerUrl = '$THUNDER_SERVER_URL/notifications'; + +Future sendAuthTokenToNotificationServer({ + required NotificationType type, + required String token, + String? endpoint, +}) async { + try { + // Send POST request to notification server + http.Response response = await http.post( + Uri.parse(notificationServerUrl), + headers: {'Content-Type': 'application/json'}, + body: { + 'type': type.name, + 'token': token, + 'endpoint': endpoint, + }, + ); + + // Check if the request was successful + if (response.statusCode == 200) return true; + return false; + } catch (e) { + return false; + } +} + +Future deleteAccountFromNotificationServer(String token) async { + try { + List accounts = await Account.accounts(); + + // Send DELETE request to notification server + http.Response response = await http.delete( + Uri.parse(notificationServerUrl), + headers: {}, + body: { + 'accountIds': accounts.map((account) => account.id).toList(), + 'token': token, + }, + ); + + // Check if the request was successful + if (response.statusCode == 200) return true; + return false; + } catch (e) { + return false; + } +} diff --git a/lib/utils/notifications.dart b/lib/utils/notifications/notifications.dart similarity index 96% rename from lib/utils/notifications.dart rename to lib/utils/notifications/notifications.dart index 355a4de51..7fdc545f5 100644 --- a/lib/utils/notifications.dart +++ b/lib/utils/notifications/notifications.dart @@ -32,9 +32,9 @@ const int _repliesGroupSummaryId = 0; /// Displays a push notification on Android void showAndroidNotification({ required int id, + required BigTextStyleInformation bigTextStyleInformation, String title = '', String content = '', - required BigTextStyleInformation bigTextStyleInformation, String payload = '', String summaryText = '', }) async { @@ -51,17 +51,10 @@ void showAndroidNotification({ final NotificationDetails notificationDetails = NotificationDetails(android: androidNotificationDetails); // Show the notification! - await flutterLocalNotificationsPlugin.show( - id, - title, - content, - notificationDetails, - payload: payload, - ); + await flutterLocalNotificationsPlugin.show(id, title, content, notificationDetails, payload: payload); // Create a summary notification for the group. - // Note that it's ok to create this for every message, because it has a fixed ID, - // so it will just get 'updated'. + // Note that it's ok to create this for every message, because it has a fixed ID, so it will just get 'updated'. final InboxStyleInformation inboxStyleInformationSummary = InboxStyleInformation( [], contentTitle: '', From 6b8d985c99844344133c81c618c0e8b3fc9eb09e Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Thu, 18 Apr 2024 13:35:49 -0700 Subject: [PATCH 07/18] code cleanup --- ios/Podfile.lock | 19 ++++++------------- lib/core/enums/local_settings.dart | 3 --- lib/core/enums/notification_type.dart | 16 ++++++++++++---- lib/l10n/app_en.arb | 16 ++++++++++++++++ lib/shared/picker_item.dart | 2 -- lib/utils/notifications.dart | 3 ++- lib/utils/preferences.dart | 4 ++-- 7 files changed, 38 insertions(+), 25 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 003e62ebf..0946d5d4c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -30,11 +30,10 @@ PODS: - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter - - push_ios (0.0.1): - - Flutter - - receive_sharing_intent (0.0.1): - pointer_interceptor_ios (0.0.1): - Flutter + - push_ios (0.0.1): + - Flutter - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -81,9 +80,8 @@ DEPENDENCIES: - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - - push_ios (from `.symlinks/plugins/push_ios/ios`) - - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`) + - push_ios (from `.symlinks/plugins/push_ios/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite (from `.symlinks/plugins/sqflite/darwin`) @@ -127,12 +125,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" - push_ios: - :path: ".symlinks/plugins/push_ios/ios" - receive_sharing_intent: - :path: ".symlinks/plugins/receive_sharing_intent/ios" pointer_interceptor_ios: :path: ".symlinks/plugins/pointer_interceptor_ios/ios" + push_ios: + :path: ".symlinks/plugins/push_ios/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: @@ -163,12 +159,9 @@ SPEC CHECKSUMS: image_picker_ios: b545a5f16c0fa88e3ecbbce3ed4de45567a8ec18 package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c - permission_handler_apple: 036b856153a2b1f61f21030ff725f3e6fece2b78 - push_ios: 2bd1b4d3f782209da1f571db1250af236957e807 - receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1 - share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028 permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 pointer_interceptor_ios: 9280618c0b2eeb80081a343924aa8ad756c21375 + push_ios: 2bd1b4d3f782209da1f571db1250af236957e807 share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec diff --git a/lib/core/enums/local_settings.dart b/lib/core/enums/local_settings.dart index 13b0c1b97..fa3bf9398 100644 --- a/lib/core/enums/local_settings.dart +++ b/lib/core/enums/local_settings.dart @@ -101,9 +101,6 @@ enum LocalSettings { showUpdateChangelogs(name: 'setting_show_update_changelogs', key: 'showUpdateChangelogs', category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.notifications), scoreCounters(name: 'setting_score_counters', key: "showScoreCounters", category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.feed), appLanguageCode(name: 'setting_app_language_code', key: 'appLanguage', category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.feedTypeAndSorts), - @Deprecated('Use inboxNotificationType instead') - enableInboxNotifications( - name: 'setting_enable_inbox_notifications', key: 'enableInboxNotifications', category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.notifications), inboxNotificationType(name: 'setting_inbox_notification_type', key: 'inboxNotificationType', category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.notifications), /// -------------------------- Feed Post Related Settings -------------------------- diff --git a/lib/core/enums/notification_type.dart b/lib/core/enums/notification_type.dart index 1a1f3a52a..6121d87e1 100644 --- a/lib/core/enums/notification_type.dart +++ b/lib/core/enums/notification_type.dart @@ -1,3 +1,9 @@ +// Package imports +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +// Project imports +import 'package:thunder/utils/global_context.dart'; + enum NotificationType { none, local, @@ -6,15 +12,17 @@ enum NotificationType { @override String toString() { + final l10n = AppLocalizations.of(GlobalContext.context)!; + switch (this) { case NotificationType.none: - return 'None'; + return l10n.none; case NotificationType.local: - return 'Local Notifications'; + return l10n.localNotifications; case NotificationType.unifiedPush: - return 'Unified Push Notifications'; + return l10n.unifiedPushNotifications; case NotificationType.apn: - return 'Apple Push Notification Service'; + return l10n.applePushNotificationService; } } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 837bc1e74..b423272e0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -79,6 +79,10 @@ "@appearance": { "description": "Title of Appearance in Settings -> Appearance" }, + "applePushNotificationService": "Apple Push Notification Service", + "@applePushNotificationService": { + "description": "Describes the notification type for Apple Push Notification Service" + }, "applied": "Applied", "@applied": {}, "apply": "Apply", @@ -861,6 +865,10 @@ "@loadMoreSingular": {}, "local": "Local", "@local": {}, + "localNotifications": "Local Notifications", + "@localNotifications": { + "description": "Describes the notification type for Local Notifications" + }, "localPosts": "Local Posts", "@localPosts": {}, "lockPost": "Lock Post", @@ -1059,6 +1067,10 @@ "@noUserBlocks": {}, "noUsersFound": "No users found.", "@noUsersFound": {}, + "none": "None", + "@none": { + "description": "Describes the notification type when push notifications are disabled" + }, "normal": "Normal", "@normal": { "description": "Normal name thickness/weight" @@ -1875,6 +1887,10 @@ "@unhidCommunity": { "description": "Short decription for moderator action to unhide a community" }, + "unifiedPushNotifications": "Unified Push Notifications", + "@unifiedPushNotifications": { + "description": "Describes the notification type for Unified Push Notifications" + }, "unlockPost": "Unlock Post", "@unlockPost": { "description": "Action for unlocking a post (moderator action)" diff --git a/lib/shared/picker_item.dart b/lib/shared/picker_item.dart index 9f061d055..d652f1d5d 100644 --- a/lib/shared/picker_item.dart +++ b/lib/shared/picker_item.dart @@ -50,8 +50,6 @@ class PickerItem extends StatelessWidget { style: (textTheme?.bodyMedium ?? theme.textTheme.bodyMedium)?.copyWith( color: (textTheme?.bodyMedium ?? theme.textTheme.bodyMedium)?.color?.withOpacity(0.5), ), - // softWrap: false, - // overflow: TextOverflow.fade, ) : null, leading: icon != null ? Icon(icon) : this.leading, diff --git a/lib/utils/notifications.dart b/lib/utils/notifications.dart index a444c7039..795480d9e 100644 --- a/lib/utils/notifications.dart +++ b/lib/utils/notifications.dart @@ -10,6 +10,7 @@ import 'package:thunder/comment/utils/comment.dart'; import 'package:thunder/core/auth/helpers/fetch_account.dart'; import 'package:thunder/core/enums/full_name.dart'; import 'package:thunder/core/enums/local_settings.dart'; +import 'package:thunder/core/enums/notification_type.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/main.dart'; @@ -37,7 +38,7 @@ Future pollRepliesAndShowNotifications() async { final FullNameSeparator communitySeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.communityFormat.name) ?? FullNameSeparator.dot.name); // We shouldn't even come here if the setting is disabled, but just in case, exit. - if (prefs.getBool(LocalSettings.enableInboxNotifications.name) != true) return; + if (NotificationType.values.byName(prefs.getString(LocalSettings.inboxNotificationType.name) ?? NotificationType.none.name) != NotificationType.local) return; // Ensure that the db is initialized before attempting to access below. await initializeDatabase(); diff --git a/lib/utils/preferences.dart b/lib/utils/preferences.dart index a6fecf6e6..485c52f9f 100644 --- a/lib/utils/preferences.dart +++ b/lib/utils/preferences.dart @@ -32,9 +32,9 @@ Future performSharedPreferencesMigration() async { } // Migrate the enableInboxNotifications setting, if found. - bool? legacyEnableInboxNotifications = prefs.getBool(LocalSettings.enableInboxNotifications.name); + bool? legacyEnableInboxNotifications = prefs.getBool('setting_enable_inbox_notifications'); if (legacyEnableInboxNotifications != null) { - await prefs.remove(LocalSettings.enableInboxNotifications.name); + await prefs.remove('setting_enable_inbox_notifications'); await prefs.setString(LocalSettings.inboxNotificationType.name, legacyEnableInboxNotifications ? NotificationType.local.name : NotificationType.none.name); } } From 57544d70ee5a02e32ab53b6782587a82cbe701db Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Fri, 19 Apr 2024 13:00:17 -0700 Subject: [PATCH 08/18] added more logic to handle push notifications --- lib/l10n/app_en.arb | 40 ++++++++++ lib/main.dart | 38 ++++----- lib/settings/pages/general_settings_page.dart | 65 ++++++++-------- .../notifications/notification_server.dart | 37 +++++---- lib/utils/notifications/notifications.dart | 77 +++++++++++++------ 5 files changed, 162 insertions(+), 95 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b423272e0..bf5d86467 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -503,6 +503,10 @@ "@disable": { "description": "Action for disabling something" }, + "disablePushNotifications": "Disable Push Notifications", + "@disablePushNotifications": { + "description": "Description when disabling push notifications" + }, "discussionLanguages": "Discussion Languages", "@discussionLanguages": { "description": "Only load posts and communities in your language(s)" @@ -621,6 +625,10 @@ }, "failedToBlock": "Failed to block: {errorMessage}", "@failedToBlock": {}, + "failedToDisablePushNotifications": "Failed to disable existing push notifications. Please try again.", + "@failedToDisablePushNotifications": { + "description": "Error message when failed to disable push notifications." + }, "failedToLoadBlocks": "Could not load blocks: {errorMessage}", "@failedToLoadBlocks": {}, "failedToUnblock": "Could not unblock: {errorMessage}", @@ -1265,6 +1273,14 @@ "@purgedPost": { "description": "Short decription for moderator action to purge a post" }, + "pushNotification": "Push Notifications", + "@pushNotification": { + "description": "Setting for push notifications" + }, + "pushNotificationDescription": "If enabled, Thunder will send your JWT token(s) to the server in order to poll for new notifications. \\n\\n **NOTE:** This will not take effect until the next time the app is launched.", + "@pushNotificationDescription": { + "description": "Description of push notification setting" + }, "reachedTheBottom": "Hmmm. It seems like you've reached the bottom.", "@reachedTheBottom": {}, "readAll": "Read All", @@ -1943,10 +1959,26 @@ "@useAdvancedShareSheet": { "description": "Toggle to use advanced share sheet." }, + "useApplePushNotifications": "Use APNs Notifications", + "@useApplePushNotifications": { + "description": "Toggle to use APNs." + }, + "useApplePushNotificationsDescription": "Uses Apple's Push Notification service", + "@useApplePushNotificationsDescription": { + "description": "Subtitle of the setting for using APNs" + }, "useCompactView": "Enable for small posts, disable for big.", "@useCompactView": { "description": "Option to enable or disable compact view for small posts." }, + "useLocalNotifications": "Use Local Notifications (Experimental)", + "@useLocalNotifications": { + "description": "Toggle to use local notifications." + }, + "useLocalNotificationsDescription": "Periodically checks for notifications in the background", + "@useLocalNotificationsDescription": { + "description": "Subtitle of the setting for using local notifications" + }, "useMaterialYouTheme": "Use Material You Theme", "@useMaterialYouTheme": { "description": "Toggle to use Material You theme." @@ -1957,6 +1989,14 @@ }, "useSuggestedTitle": "Use suggested title: {title}", "@useSuggestedTitle": {}, + "useUnifiedPushNotifications": "Use UnifiedPush Notifications", + "@useUnifiedPushNotifications": { + "description": "Toggle to use UnifiedPush Notifications" + }, + "useUnifiedPushNotificationsDescription": "Requires a compatible app", + "@useUnifiedPushNotificationsDescription": { + "description": "Subtitle of the setting for using UnifiedPush Notifications" + }, "user": "User", "@user": { "description": "Role name for user" diff --git a/lib/main.dart b/lib/main.dart index 12e528267..2ce60371e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,58 +1,49 @@ -import 'dart:io'; +// Dart imports import 'dart:async'; +import 'dart:io'; +// Flutter imports +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -// External Packages +// Package imports +import "package:flutter_displaymode/flutter_displaymode.dart"; import 'package:dart_ping_ios/dart_ping_ios.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import "package:flutter_displaymode/flutter_displaymode.dart"; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:l10n_esperanto/l10n_esperanto.dart'; import 'package:overlay_support/overlay_support.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:thunder/core/enums/notification_type.dart'; -import 'package:dynamic_color/dynamic_color.dart'; -import 'package:flex_color_scheme/flex_color_scheme.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; + +// Project imports import 'package:thunder/account/bloc/account_bloc.dart'; import 'package:thunder/community/bloc/anonymous_subscriptions_bloc.dart'; import 'package:thunder/community/bloc/community_bloc.dart'; +import 'package:thunder/core/auth/bloc/auth_bloc.dart'; import 'package:thunder/core/database/database.dart'; import 'package:thunder/core/database/migrations.dart'; import 'package:thunder/core/enums/local_settings.dart'; +import 'package:thunder/core/enums/notification_type.dart'; +import 'package:thunder/core/enums/theme_type.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/core/singletons/preferences.dart'; +import 'package:thunder/core/theme/bloc/theme_bloc.dart'; import 'package:thunder/instance/bloc/instance_bloc.dart'; - -// Internal Packages import 'package:thunder/routes.dart'; -import 'package:thunder/core/enums/theme_type.dart'; -import 'package:thunder/core/theme/bloc/theme_bloc.dart'; -import 'package:thunder/core/auth/bloc/auth_bloc.dart'; import 'package:thunder/thunder/cubits/notifications_cubit/notifications_cubit.dart'; import 'package:thunder/thunder/thunder.dart'; import 'package:thunder/user/bloc/user_bloc.dart'; import 'package:thunder/utils/cache.dart'; import 'package:thunder/utils/global_context.dart'; -import 'package:flutter/foundation.dart'; import 'package:thunder/utils/notifications/notifications.dart'; import 'package:thunder/utils/preferences.dart'; -import 'package:thunder/account/bloc/account_bloc.dart'; -import 'package:thunder/community/bloc/community_bloc.dart'; -import 'package:thunder/core/enums/local_settings.dart'; -import 'package:thunder/core/singletons/lemmy_client.dart'; -import 'package:thunder/core/singletons/preferences.dart'; -import 'package:thunder/instance/bloc/instance_bloc.dart'; -import 'package:thunder/community/bloc/anonymous_subscriptions_bloc.dart'; late AppDatabase database; @@ -82,9 +73,6 @@ void main() async { // Setting SystemUIMode SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - // Load up preferences - SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - await initializeDatabase(); // Clear image cache @@ -126,7 +114,7 @@ class _ThunderAppState extends State { WidgetsBinding.instance.addPostFrameCallback((_) async { SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - print(prefs.getString(LocalSettings.inboxNotificationType.name)); + if (NotificationType.values.byName(prefs.getString(LocalSettings.inboxNotificationType.name) ?? NotificationType.none.name) != NotificationType.none) { // Initialize notification logic initPushNotificationLogic(controller: notificationsStreamController); diff --git a/lib/settings/pages/general_settings_page.dart b/lib/settings/pages/general_settings_page.dart index 2d4df778b..d1fe93621 100644 --- a/lib/settings/pages/general_settings_page.dart +++ b/lib/settings/pages/general_settings_page.dart @@ -32,6 +32,7 @@ import 'package:thunder/utils/bottom_sheet_list_picker.dart'; import 'package:thunder/utils/constants.dart'; import 'package:thunder/utils/language/language.dart'; import 'package:thunder/utils/links.dart'; +import 'package:thunder/utils/notifications/notification_server.dart'; import 'package:thunder/utils/notifications/notifications.dart'; import 'package:unifiedpush/unifiedpush.dart'; import 'package:version/version.dart'; @@ -666,51 +667,49 @@ class _GeneralSettingsPageState extends State with SingleTi customListPicker: StatefulBuilder( builder: (context, setState) { return BottomSheetListPicker( - title: "Push Notifications", - heading: const Align( + title: l10n.pushNotification, + heading: Align( alignment: Alignment.centerLeft, - child: CommonMarkdownBody( - body: - "If enabled, Thunder will send your JWT token(s) to the server in order to poll for new notifications. \n\n **NOTE:** This will not take effect until the next time the app is launched."), + child: CommonMarkdownBody(body: l10n.pushNotificationDescription), ), previouslySelected: inboxNotificationType, items: Platform.isAndroid ? [ - const ListPickerItem( + ListPickerItem( icon: Icons.notifications_off_rounded, - label: "None", + label: l10n.none, payload: NotificationType.none, ), - const ListPickerItem( + ListPickerItem( icon: Icons.notifications_rounded, - label: "Use Local Notifications (Experimental)", - subtitle: "Periodically checks for notifications in the background", + label: l10n.useLocalNotifications, + subtitle: l10n.useLocalNotificationsDescription, payload: NotificationType.local, ), - const ListPickerItem( + ListPickerItem( icon: Icons.notifications_active_rounded, - label: "Use UnifiedPush Notifications", - subtitle: "Requires a compatible app", + label: l10n.useUnifiedPushNotifications, + subtitle: l10n.useUnifiedPushNotificationsDescription, payload: NotificationType.unifiedPush, ), ] : [ - const ListPickerItem( + ListPickerItem( icon: Icons.notifications_off_rounded, - label: "Disable Push Notifications", + label: l10n.disablePushNotifications, payload: NotificationType.none, ), - const ListPickerItem( + ListPickerItem( icon: Icons.notifications_active_rounded, - label: "Use APNs Notifications", - subtitle: "Uses Apple's Push Notification service", + label: l10n.useApplePushNotifications, + subtitle: l10n.useApplePushNotificationsDescription, payload: NotificationType.apn, ), ], onSelect: (ListPickerItem notificationType) async { if (notificationType.payload == inboxNotificationType) return; - // Disable all notifications since the option has changed + // Disable all notifications since the option has changed. if (Platform.isAndroid) { disableBackgroundFetch(); UnifiedPush.unregister(); @@ -718,9 +717,18 @@ class _GeneralSettingsPageState extends State with SingleTi // TODO: Disable APNs } + // Delete all server tokens related to all accounts if the option was previously unified push or apns + if (inboxNotificationType == NotificationType.unifiedPush || inboxNotificationType == NotificationType.apn) { + bool success = await deleteAccountFromNotificationServer(); + if (!success) { + showSnackbar(l10n.failedToDisablePushNotifications); + return; + } + } + // If disabled, do nothing if (notificationType.payload == NotificationType.none) { - setPreferences(LocalSettings.inboxNotificationType, notificationType.payload.name); + setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); return; } @@ -772,18 +780,11 @@ class _GeneralSettingsPageState extends State with SingleTi if (areAndroidNotificationsAllowed != true) { areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.requestNotificationsPermission(); - if (areAndroidNotificationsAllowed != true) return showSnackbar('Failed to request Android notifications permissions.'); + if (areAndroidNotificationsAllowed != true) return showSnackbar(l10n.permissionDenied); } // Permissions have been granted, so we can enable notifications - if (notificationType.payload == NotificationType.local) { - initBackgroundFetch(); - initHeadlessBackgroundFetch(); - setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); - } else if (notificationType.payload == NotificationType.unifiedPush) { - // TODO: set up a way to enable UnifiedPush without app restart - setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); - } + setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); break; case NotificationType.apn: // We're on iOS. Request notifications permissions if needed. @@ -794,10 +795,10 @@ class _GeneralSettingsPageState extends State with SingleTi if (notificationsEnabledOptions?.isEnabled != true) { bool? isEnabled = await iosFlutterLocalNotificationsPlugin?.requestPermissions(alert: true, badge: true, sound: true); - if (isEnabled != true) return showSnackbar('Failed to request iOS notifications permissions.'); - // TODO: set up a way to enable APNs without app restart - setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); + if (isEnabled != true) return showSnackbar(l10n.permissionDenied); } + + setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); break; default: break; diff --git a/lib/utils/notifications/notification_server.dart b/lib/utils/notifications/notification_server.dart index 1001acd0d..9ff1ec69f 100644 --- a/lib/utils/notifications/notification_server.dart +++ b/lib/utils/notifications/notification_server.dart @@ -1,48 +1,59 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; -import 'package:thunder/account/models/account.dart'; +import 'package:thunder/account/models/account.dart'; import 'package:thunder/core/enums/notification_type.dart'; import 'package:thunder/utils/constants.dart'; -String notificationServerUrl = '$THUNDER_SERVER_URL/notifications'; +// String notificationServerUrl = '$THUNDER_SERVER_URL/notifications'; +String notificationServerUrl = 'http://192.168.50.195:5100/notifications'; Future sendAuthTokenToNotificationServer({ required NotificationType type, required String token, - String? endpoint, + required List jwts, + required String instance, }) async { try { // Send POST request to notification server http.Response response = await http.post( Uri.parse(notificationServerUrl), - headers: {'Content-Type': 'application/json'}, - body: { + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: jsonEncode({ 'type': type.name, 'token': token, - 'endpoint': endpoint, - }, + 'jwts': jwts, + 'instance': instance, + }), ); // Check if the request was successful if (response.statusCode == 200) return true; return false; } catch (e) { + debugPrint(e.toString()); return false; } } -Future deleteAccountFromNotificationServer(String token) async { +Future deleteAccountFromNotificationServer() async { try { List accounts = await Account.accounts(); + List jwts = accounts.map((Account account) => account.jwt!).toList(); - // Send DELETE request to notification server + // Send POST request to notification server http.Response response = await http.delete( Uri.parse(notificationServerUrl), - headers: {}, - body: { - 'accountIds': accounts.map((account) => account.id).toList(), - 'token': token, + headers: { + 'Content-Type': 'application/json; charset=UTF-8', }, + body: jsonEncode({ + 'jwts': jwts, + }), ); // Check if the request was successful diff --git a/lib/utils/notifications/notifications.dart b/lib/utils/notifications/notifications.dart index 7fdc545f5..29120221a 100644 --- a/lib/utils/notifications/notifications.dart +++ b/lib/utils/notifications/notifications.dart @@ -21,6 +21,7 @@ import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/utils/global_context.dart'; import 'package:thunder/utils/instance.dart'; +import 'package:thunder/utils/notifications/notification_server.dart'; import 'package:unifiedpush/unifiedpush.dart'; const String _inboxMessagesChannelId = 'inbox_messages'; @@ -81,12 +82,13 @@ void showAndroidNotification({ ); } +/// The main function which triggers push notification logic. This handles both Android and iOS. +/// +/// The [controller] is passed in so that we can react to push notifications. Future initPushNotificationLogic({required StreamController controller}) async { if (Platform.isAndroid) { initAndroidPushNotificationLogic(controller: controller); - } - - if (Platform.isIOS) { + } else if (Platform.isIOS) { initIOSPushNotificationLogic(controller: controller); } } @@ -101,8 +103,14 @@ void initAndroidPushNotificationLogic({required StreamController accounts = await Account.accounts(); + + for (Account account in accounts) { + // TODO: Select accounts to enable push notifications + bool success = await sendAuthTokenToNotificationServer(type: notificationType, token: endpoint, jwts: [account.jwt!], instance: account.instance!); + } }, onRegistrationFailed: (String instance) { debugPrint("UnifiedPush registration failed for $instance"); @@ -145,49 +153,68 @@ void initAndroidPushNotificationLogic({required StreamController controller.add(notificationResponse), - ); + // Initialize the Flutter Local Notifications plugin for both UnifiedPush and Local notifications + FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - // See if Thunder is launching because a notification was tapped. If so, we want to jump right to the appropriate page. - final NotificationAppLaunchDetails? notificationAppLaunchDetails = await FlutterLocalNotificationsPlugin().getNotificationAppLaunchDetails(); + // Initialize the Android-specific settings, using the splash asset as the notification icon. + const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('splash'); + const InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid); - if (notificationAppLaunchDetails?.didNotificationLaunchApp == true && notificationAppLaunchDetails?.notificationResponse != null) { - controller.add(notificationAppLaunchDetails!.notificationResponse!); + await flutterLocalNotificationsPlugin.initialize( + initializationSettings, + onDidReceiveNotificationResponse: (notificationResponse) => controller.add(notificationResponse), + ); - bool startupDueToGroupNotification = notificationAppLaunchDetails.notificationResponse!.payload == repliesGroupKey; - // Do a notifications check on startup, if the user isn't clicking on a group notification - if (!startupDueToGroupNotification) pollRepliesAndShowNotifications(); - } + // See if Thunder is launching because a notification was tapped. If so, we want to jump right to the appropriate page. + final NotificationAppLaunchDetails? notificationAppLaunchDetails = await FlutterLocalNotificationsPlugin().getNotificationAppLaunchDetails(); - // Initialize background fetch (this is async and can go run on its own). - initBackgroundFetch(); + if (notificationAppLaunchDetails?.didNotificationLaunchApp == true && notificationAppLaunchDetails?.notificationResponse != null) { + controller.add(notificationAppLaunchDetails!.notificationResponse!); - // Register to receive BackgroundFetch events after app is terminated. - initHeadlessBackgroundFetch(); + bool startupDueToGroupNotification = notificationAppLaunchDetails.notificationResponse!.payload == repliesGroupKey; + // Do a notifications check on startup, if the user isn't clicking on a group notification + if (!startupDueToGroupNotification && notificationType == NotificationType.local) pollRepliesAndShowNotifications(); } } /// Initialize iOS specific notification logic. This is only called when the app is running on iOS. void initIOSPushNotificationLogic({required StreamController controller}) async { + SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; + NotificationType notificationType = NotificationType.values.byName(prefs.getString(LocalSettings.inboxNotificationType.name) ?? NotificationType.none.name); + + // Return if we don't have the expected types + if (notificationType != NotificationType.apn) return; + // Fetch device token for APNs String? token = await Push.instance.token; if (token == null) return; + List accounts = await Account.accounts(); + /// We need to send this device token along with the jwt so that the server can poll for new notifications and send them to this device. debugPrint("Device token: $token"); + for (Account account in accounts) { + // TODO: Select accounts to enable push notifications + bool success = await sendAuthTokenToNotificationServer(type: notificationType, token: token, jwts: [account.jwt!], instance: account.instance!); + } + // Handle new tokens generated from the device - Push.instance.onNewToken.listen((token) { + Push.instance.onNewToken.listen((token) async { /// We need to send this device token along with the jwt so that the server can poll for new notifications and send them to this device. debugPrint("Received new device token: $token"); + + for (Account account in accounts) { + // TODO: Select accounts to enable push notifications + bool success = await sendAuthTokenToNotificationServer(type: notificationType, token: token, jwts: [account.jwt!], instance: account.instance!); + } }); // Handle notification launching app from terminated state From f615f032db2a5c6c2ecdb16b9b01f9f170430271 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Fri, 19 Apr 2024 18:03:47 -0700 Subject: [PATCH 09/18] refactored notifications, moved notification logic to its own directory --- lib/l10n/app_en.arb | 5 +- lib/main.dart | 2 +- lib/notification/notifications.dart | 67 ++++ .../shared/android_notification.dart | 71 ++++ .../shared}/notification_server.dart | 5 + lib/notification/utils/apns.dart | 72 ++++ .../utils/local_notifications.dart | 174 +++++++++ .../utils/navigate_notification.dart} | 126 +++--- lib/notification/utils/unified_push.dart | 141 +++++++ lib/settings/pages/general_settings_page.dart | 16 +- .../notifications_cubit.dart | 3 +- lib/thunder/pages/thunder_page.dart | 2 +- lib/utils/notifications.dart | 130 ------- lib/utils/notifications/notifications.dart | 368 ------------------ 14 files changed, 615 insertions(+), 567 deletions(-) create mode 100644 lib/notification/notifications.dart create mode 100644 lib/notification/shared/android_notification.dart rename lib/{utils/notifications => notification/shared}/notification_server.dart (95%) create mode 100644 lib/notification/utils/apns.dart create mode 100644 lib/notification/utils/local_notifications.dart rename lib/{utils/notifications_navigation.dart => notification/utils/navigate_notification.dart} (97%) create mode 100644 lib/notification/utils/unified_push.dart delete mode 100644 lib/utils/notifications.dart delete mode 100644 lib/utils/notifications/notifications.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bf5d86467..e55445a6d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2078,5 +2078,6 @@ "description": "The total score of post or comment" }, "xUpvotes": "{x} upvotes", - "@xUpvotes": {} -} \ No newline at end of file + "@xUpvotes": {}, + "disabled": "Disabled" +} diff --git a/lib/main.dart b/lib/main.dart index 2ce60371e..293765806 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -36,13 +36,13 @@ import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/core/theme/bloc/theme_bloc.dart'; import 'package:thunder/instance/bloc/instance_bloc.dart'; +import 'package:thunder/notification/notifications.dart'; import 'package:thunder/routes.dart'; import 'package:thunder/thunder/cubits/notifications_cubit/notifications_cubit.dart'; import 'package:thunder/thunder/thunder.dart'; import 'package:thunder/user/bloc/user_bloc.dart'; import 'package:thunder/utils/cache.dart'; import 'package:thunder/utils/global_context.dart'; -import 'package:thunder/utils/notifications/notifications.dart'; import 'package:thunder/utils/preferences.dart'; late AppDatabase database; diff --git a/lib/notification/notifications.dart b/lib/notification/notifications.dart new file mode 100644 index 000000000..86f1d699c --- /dev/null +++ b/lib/notification/notifications.dart @@ -0,0 +1,67 @@ +// Dart imports +import 'dart:async'; + +// Flutter imports +import 'package:flutter/foundation.dart'; + +// Package imports +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// Project imports +import 'package:thunder/core/enums/local_settings.dart'; +import 'package:thunder/core/enums/notification_type.dart'; +import 'package:thunder/core/singletons/preferences.dart'; +import 'package:thunder/notification/shared/android_notification.dart'; +import 'package:thunder/notification/utils/apns.dart'; +import 'package:thunder/notification/utils/local_notifications.dart'; +import 'package:thunder/notification/utils/unified_push.dart'; + +/// The main function which triggers push notification logic. This handles delegating push notification logic to the correct service. +/// +/// The [controller] is passed in so that we can react to push notifications. +Future initPushNotificationLogic({required StreamController controller}) async { + SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; + NotificationType notificationType = NotificationType.values.byName(prefs.getString(LocalSettings.inboxNotificationType.name) ?? NotificationType.none.name); + + debugPrint("Initializing push notifications for type: ${notificationType.name}"); + + switch (notificationType) { + case NotificationType.local: + initLocalNotifications(controller: controller); + break; + case NotificationType.unifiedPush: + initUnifiedPushNotifications(controller: controller); + break; + case NotificationType.apn: + initAPNs(controller: controller); + break; + default: + break; + } + + // Initialize the Flutter Local Notifications plugin for both UnifiedPush and Local notifications + if (notificationType == NotificationType.local || notificationType == NotificationType.unifiedPush) { + FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + + // Initialize the Android-specific settings, using the splash asset as the notification icon. + const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('splash'); + const InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid); + + await flutterLocalNotificationsPlugin.initialize( + initializationSettings, + onDidReceiveNotificationResponse: (notificationResponse) => controller.add(notificationResponse), + ); + + // See if Thunder is launching because a notification was tapped. If so, we want to jump right to the appropriate page. + final NotificationAppLaunchDetails? notificationAppLaunchDetails = await FlutterLocalNotificationsPlugin().getNotificationAppLaunchDetails(); + + if (notificationAppLaunchDetails?.didNotificationLaunchApp == true && notificationAppLaunchDetails?.notificationResponse != null) { + controller.add(notificationAppLaunchDetails!.notificationResponse!); + + bool startupDueToGroupNotification = notificationAppLaunchDetails.notificationResponse!.payload == repliesGroupKey; + // Do a notifications check on startup, if the user isn't clicking on a group notification + if (!startupDueToGroupNotification && notificationType == NotificationType.local) pollRepliesAndShowNotifications(); + } + } +} diff --git a/lib/notification/shared/android_notification.dart b/lib/notification/shared/android_notification.dart new file mode 100644 index 000000000..f73969843 --- /dev/null +++ b/lib/notification/shared/android_notification.dart @@ -0,0 +1,71 @@ +// Package imports +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +// Project imports +import 'package:thunder/account/models/account.dart'; + +const String _inboxMessagesChannelName = 'Inbox Messages'; +const String repliesGroupKey = 'replies'; + +/// Displays a new notification group on Android based on the accounts passed in. +void showNotificationGroups({ + List accounts = const [], +}) async { + final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + + for (Account account in accounts) { + // Create a summary notification for the group. + final InboxStyleInformation inboxStyleInformationSummary = InboxStyleInformation( + [], + contentTitle: '', + summaryText: '${account.username}@${account.instance}', + ); + + final AndroidNotificationDetails androidNotificationDetailsSummary = AndroidNotificationDetails( + account.id, + _inboxMessagesChannelName, + styleInformation: inboxStyleInformationSummary, + groupKey: account.id, + setAsGroupSummary: true, + ); + + final NotificationDetails notificationDetailsSummary = NotificationDetails(android: androidNotificationDetailsSummary); + + // Send the summary message! + await flutterLocalNotificationsPlugin.show( + account.id.hashCode, + '', + '', + notificationDetailsSummary, + payload: repliesGroupKey, + ); + } +} + +/// Displays a single push notification on Android +/// +/// The notification will be grouped based on the account id. +void showAndroidNotification({ + required int id, + required BigTextStyleInformation bigTextStyleInformation, + Account? account, + String title = '', + String content = '', + String payload = '', + String summaryText = '', +}) async { + final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + + // Configure Android-specific settings + final AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails( + account?.id ?? 'default', + _inboxMessagesChannelName, + styleInformation: bigTextStyleInformation, + groupKey: account?.id ?? 'default', + ); + + final NotificationDetails notificationDetails = NotificationDetails(android: androidNotificationDetails); + + // Show the notification! + await flutterLocalNotificationsPlugin.show(id, title, content, notificationDetails, payload: payload); +} diff --git a/lib/utils/notifications/notification_server.dart b/lib/notification/shared/notification_server.dart similarity index 95% rename from lib/utils/notifications/notification_server.dart rename to lib/notification/shared/notification_server.dart index 9ff1ec69f..a4abe6e1c 100644 --- a/lib/utils/notifications/notification_server.dart +++ b/lib/notification/shared/notification_server.dart @@ -1,8 +1,13 @@ +// Dart imports: import 'dart:convert'; +// Flutter imports: import 'package:flutter/foundation.dart'; + +// Package imports: import 'package:http/http.dart' as http; +// Project imports: import 'package:thunder/account/models/account.dart'; import 'package:thunder/core/enums/notification_type.dart'; import 'package:thunder/utils/constants.dart'; diff --git a/lib/notification/utils/apns.dart b/lib/notification/utils/apns.dart new file mode 100644 index 000000000..22bec2e83 --- /dev/null +++ b/lib/notification/utils/apns.dart @@ -0,0 +1,72 @@ +// Dart imports +import 'dart:async'; + +// Flutter imports +import 'package:flutter/material.dart'; + +// Package imports +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:push/push.dart'; + +// Project imports +import 'package:thunder/account/models/account.dart'; +import 'package:thunder/core/enums/notification_type.dart'; +import 'package:thunder/notification/shared/notification_server.dart'; + +/// Initializes push notifications for APNs (Apple Push Notifications service). +/// For now, initializing APNs will enable push notifications for all accounts active on the app. +/// +/// The [controller] is passed in so that we can react to push notifications when the user taps on the notification. +void initAPNs({required StreamController controller}) async { + const String repliesGroupKey = 'replies'; + + // Fetch device token for APNs + // We need to send this device token along with the jwt so that the server can poll for new notifications and send them to this device. + String? token = await Push.instance.token; + debugPrint("Device token: $token"); + + if (token == null) { + debugPrint("No device token, skipping APNs initialization"); + return; + } + + // Fetch all the currently logged in accounts + List accounts = await Account.accounts(); + + // TODO: Select accounts to enable push notifications + for (Account account in accounts) { + bool success = await sendAuthTokenToNotificationServer(type: NotificationType.apn, token: token, jwts: [account.jwt!], instance: account.instance!); + if (!success) debugPrint("Failed to send device token to server for account ${account.id}. Skipping."); + } + + // Handle new tokens generated from the device + Push.instance.onNewToken.listen((token) async { + debugPrint("Received new device token: $token"); + + // We should remove any previously sent tokens, and send them again + bool removed = await deleteAccountFromNotificationServer(); + if (!removed) debugPrint("Failed to delete previous device token from server."); + + // TODO: Select accounts to enable push notifications + for (Account account in accounts) { + bool success = await sendAuthTokenToNotificationServer(type: NotificationType.apn, token: token, jwts: [account.jwt!], instance: account.instance!); + if (!success) debugPrint("Failed to send device token to server for account ${account.id}. Skipping."); + } + }); + + // Handle notification launching app from terminated state + Push.instance.notificationTapWhichLaunchedAppFromTerminated.then((data) { + if (data == null) return; + + if (data.containsKey(repliesGroupKey)) { + controller.add(NotificationResponse(payload: data[repliesGroupKey] as String, notificationResponseType: NotificationResponseType.selectedNotification)); + } + }); + + /// Handle notification taps. This triggers when the user taps on a notification when the app is on the foreground or background. + Push.instance.onNotificationTap.listen((data) { + if (data.containsKey(repliesGroupKey)) { + controller.add(NotificationResponse(payload: data[repliesGroupKey] as String, notificationResponseType: NotificationResponseType.selectedNotification)); + } + }); +} diff --git a/lib/notification/utils/local_notifications.dart b/lib/notification/utils/local_notifications.dart new file mode 100644 index 000000000..62da23965 --- /dev/null +++ b/lib/notification/utils/local_notifications.dart @@ -0,0 +1,174 @@ +// Dart imports +import 'dart:async'; + +// Flutter imports +import 'package:flutter/material.dart'; + +// Package imports +import 'package:background_fetch/background_fetch.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:html/parser.dart'; +import 'package:lemmy_api_client/v3.dart'; +import 'package:markdown/markdown.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// Project imports +import 'package:thunder/account/models/account.dart'; +import 'package:thunder/comment/utils/comment.dart'; +import 'package:thunder/core/enums/full_name.dart'; +import 'package:thunder/core/enums/local_settings.dart'; +import 'package:thunder/core/singletons/lemmy_client.dart'; +import 'package:thunder/core/singletons/preferences.dart'; +import 'package:thunder/main.dart'; +import 'package:thunder/notification/shared/android_notification.dart'; +import 'package:thunder/utils/instance.dart'; + +const String _lastPollTimeId = 'thunder_last_notifications_poll_time'; + +/// Initializes push notifications for local notifications (background service). +/// For now, initializing local notifications will enable push notifications for all accounts active on the app. +/// +/// The [controller] is passed in so that we can react to push notifications when the user taps on the notification. +void initLocalNotifications({required StreamController controller}) async { + // Initialize background fetch (this is async and can go run on its own). + initBackgroundFetch(); + + // Register to receive BackgroundFetch events after app is terminated. + initHeadlessBackgroundFetch(); +} + +/// This method polls for new inbox messages and, if found, displays them as notificatons. It is intended to be invoked from a background fetch task. +/// It will track when the last poll ran and ignore any inbox messages from before that time. +/// +/// If the user has not configured inbox notifications, it will do nothing. If no user is logged in, it will do nothing. +Future pollRepliesAndShowNotifications() async { + // This print statement is here for the sake of verifying that background checks only happen when they're supposed to. + // If we see this line outputted when notifications are disabled, then something is wrong with our configuration of background_fetch. + debugPrint('Thunder - Background fetch - Running notification poll'); + + final SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; + final FullNameSeparator userSeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.userFormat.name) ?? FullNameSeparator.at.name); + final FullNameSeparator communitySeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.communityFormat.name) ?? FullNameSeparator.dot.name); + + // Ensure that the db is initialized before attempting to access below. + await initializeDatabase(); + + List accounts = await Account.accounts(); + DateTime lastPollTime = DateTime.tryParse(prefs.getString(_lastPollTimeId) ?? '') ?? DateTime.now(); + + Map> notifications = {}; + + for (final Account account in accounts) { + LemmyClient client = LemmyClient()..changeBaseUrl(account.instance!); + + // Iterate through inbox replies + GetRepliesResponse getRepliesResponse = await client.lemmyApiV3.run( + GetReplies( + auth: account.jwt!, + unreadOnly: true, + limit: 50, // Max allowed by API + sort: CommentSortType.old, + page: 1, + ), + ); + + // Only handle messages that have arrived since the last time we polled + final Iterable newReplies = getRepliesResponse.replies.where((CommentReplyView commentReplyView) => commentReplyView.commentReply.published.isAfter(lastPollTime)); + + notifications.putIfAbsent(account, () => newReplies.toList()); + } + + // Create a notification group for each account that has replies + showNotificationGroups(accounts: notifications.keys.toList()); + + // Show the notifications + for (final entry in notifications.entries) { + Account account = entry.key; + List replies = entry.value; + + for (CommentReplyView commentReplyView in replies) { + final String commentContent = cleanCommentContent(commentReplyView.comment); + final String htmlComment = markdownToHtml(commentContent); + final String plaintextComment = parse(parse(htmlComment).body?.text).documentElement?.text ?? commentContent; + + final BigTextStyleInformation bigTextStyleInformation = BigTextStyleInformation( + '${commentReplyView.post.name} · ${generateCommunityFullName(null, commentReplyView.community.name, fetchInstanceNameFromUrl(commentReplyView.community.actorId), communitySeparator: communitySeparator)}\n$htmlComment', + contentTitle: generateUserFullName(null, commentReplyView.creator.name, fetchInstanceNameFromUrl(commentReplyView.creator.actorId), userSeparator: userSeparator), + summaryText: generateUserFullName(null, commentReplyView.recipient.name, fetchInstanceNameFromUrl(commentReplyView.recipient.actorId), userSeparator: userSeparator), + htmlFormatBigText: true, + ); + + showAndroidNotification( + id: commentReplyView.commentReply.id, + account: account, + bigTextStyleInformation: bigTextStyleInformation, + title: generateUserFullName(null, commentReplyView.creator.name, fetchInstanceNameFromUrl(commentReplyView.creator.actorId), userSeparator: userSeparator), + content: plaintextComment, + payload: '$repliesGroupKey-${commentReplyView.comment.id}', + summaryText: generateUserFullName(null, commentReplyView.recipient.name, fetchInstanceNameFromUrl(commentReplyView.recipient.actorId), userSeparator: userSeparator), + ); + } + } + + // Save our poll time + prefs.setString(_lastPollTimeId, DateTime.now().toString()); +} + +// ---------------- START BACKGROUND FETCH ---------------- // + +/// This method handles "headless" callbacks (i.e., whent the app is not running) +@pragma('vm:entry-point') +void backgroundFetchHeadlessTask(HeadlessTask task) async { + if (task.timeout) return BackgroundFetch.finish(task.taskId); + + await pollRepliesAndShowNotifications(); + BackgroundFetch.finish(task.taskId); +} + +/// The method initializes background fetching while the app is running +Future initBackgroundFetch() async { + await BackgroundFetch.configure( + BackgroundFetchConfig( + minimumFetchInterval: 15, + stopOnTerminate: false, + startOnBoot: true, + enableHeadless: true, + requiredNetworkType: NetworkType.NONE, + requiresBatteryNotLow: false, + requiresStorageNotLow: false, + requiresCharging: false, + requiresDeviceIdle: false, + // Uncomment this line (and set the minimumFetchInterval to 1) for quicker testing. + // forceAlarmManager: true, + ), + // This is the callback that handles background fetching while the app is running. + (String taskId) async { + await pollRepliesAndShowNotifications(); + BackgroundFetch.finish(taskId); + }, + // This is the timeout callback. + (String taskId) async { + BackgroundFetch.finish(taskId); + }, + ); +} + +void disableBackgroundFetch() async { + await BackgroundFetch.configure( + BackgroundFetchConfig( + minimumFetchInterval: 15, + stopOnTerminate: true, + startOnBoot: false, + enableHeadless: false, + ), + () {}, + () {}, + ); +} + +// This method initializes background fetching while the app is not running +void initHeadlessBackgroundFetch() async { + BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask); +} + +// ---------------- END BACKGROUND FETCH ---------------- // diff --git a/lib/utils/notifications_navigation.dart b/lib/notification/utils/navigate_notification.dart similarity index 97% rename from lib/utils/notifications_navigation.dart rename to lib/notification/utils/navigate_notification.dart index da9ed31fa..bf17e12be 100644 --- a/lib/utils/notifications_navigation.dart +++ b/lib/notification/utils/navigate_notification.dart @@ -1,61 +1,65 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:lemmy_api_client/v3.dart'; -import 'package:swipeable_page_route/swipeable_page_route.dart'; - -import 'package:thunder/account/models/account.dart'; -import 'package:thunder/core/auth/helpers/fetch_account.dart'; -import 'package:thunder/core/singletons/lemmy_client.dart'; -import 'package:thunder/inbox/bloc/inbox_bloc.dart'; -import 'package:thunder/thunder/bloc/thunder_bloc.dart'; -import 'package:thunder/thunder/pages/notifications_pages.dart'; - -void navigateToNotificationReplyPage(BuildContext context, {required int? replyId}) async { - final ThunderBloc thunderBloc = context.read(); - final bool reduceAnimations = thunderBloc.state.reduceAnimations; - final Account? account = await fetchActiveProfileAccount(); - - List allReplies = []; - CommentReplyView? specificReply; - - bool doneFetching = false; - int currentPage = 1; - - // Load the notifications - while (!doneFetching) { - final GetRepliesResponse getRepliesResponse = await LemmyClient.instance.lemmyApiV3.run(GetReplies( - sort: CommentSortType.new_, - page: currentPage, - limit: 50, - unreadOnly: replyId == null, - auth: account?.jwt, - )); - - allReplies.addAll(getRepliesResponse.replies); - specificReply ??= getRepliesResponse.replies.firstWhereOrNull((crv) => crv.commentReply.id == replyId); - - doneFetching = specificReply != null || getRepliesResponse.replies.isEmpty; - ++currentPage; - } - - if (context.mounted) { - final NotificationsReplyPage notificationsReplyPage = NotificationsReplyPage(replies: specificReply == null ? allReplies : [specificReply]); - - Navigator.of(context) - .push( - SwipeablePageRoute( - transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, - backGestureDetectionWidth: 45, - canOnlySwipeFromEdge: !thunderBloc.state.enableFullScreenSwipeNavigationGesture, - builder: (context) => MultiBlocProvider( - providers: [ - BlocProvider.value(value: thunderBloc), - ], - child: notificationsReplyPage, - ), - ), - ) - .then((_) => context.read().add(const GetInboxEvent(reset: true))); - } -} +// Flutter imports +import 'package:flutter/material.dart'; + +// Package imports +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lemmy_api_client/v3.dart'; +import 'package:swipeable_page_route/swipeable_page_route.dart'; + +// Project imports +import 'package:thunder/account/models/account.dart'; +import 'package:thunder/core/auth/helpers/fetch_account.dart'; +import 'package:thunder/core/singletons/lemmy_client.dart'; +import 'package:thunder/inbox/bloc/inbox_bloc.dart'; +import 'package:thunder/thunder/bloc/thunder_bloc.dart'; +import 'package:thunder/thunder/pages/notifications_pages.dart'; + +void navigateToNotificationReplyPage(BuildContext context, {required int? replyId}) async { + final ThunderBloc thunderBloc = context.read(); + final bool reduceAnimations = thunderBloc.state.reduceAnimations; + final Account? account = await fetchActiveProfileAccount(); + + List allReplies = []; + CommentReplyView? specificReply; + + bool doneFetching = false; + int currentPage = 1; + + // Load the notifications + while (!doneFetching) { + final GetRepliesResponse getRepliesResponse = await LemmyClient.instance.lemmyApiV3.run(GetReplies( + sort: CommentSortType.new_, + page: currentPage, + limit: 50, + unreadOnly: replyId == null, + auth: account?.jwt, + )); + + allReplies.addAll(getRepliesResponse.replies); + specificReply ??= getRepliesResponse.replies.firstWhereOrNull((crv) => crv.commentReply.id == replyId); + + doneFetching = specificReply != null || getRepliesResponse.replies.isEmpty; + ++currentPage; + } + + if (context.mounted) { + final NotificationsReplyPage notificationsReplyPage = NotificationsReplyPage(replies: specificReply == null ? allReplies : [specificReply]); + + Navigator.of(context) + .push( + SwipeablePageRoute( + transitionDuration: reduceAnimations ? const Duration(milliseconds: 100) : null, + backGestureDetectionWidth: 45, + canOnlySwipeFromEdge: !thunderBloc.state.enableFullScreenSwipeNavigationGesture, + builder: (context) => MultiBlocProvider( + providers: [ + BlocProvider.value(value: thunderBloc), + ], + child: notificationsReplyPage, + ), + ), + ) + .then((_) => context.read().add(const GetInboxEvent(reset: true))); + } +} diff --git a/lib/notification/utils/unified_push.dart b/lib/notification/utils/unified_push.dart new file mode 100644 index 000000000..caa0fa1f7 --- /dev/null +++ b/lib/notification/utils/unified_push.dart @@ -0,0 +1,141 @@ +// Dart imports +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +// Flutter imports +import 'package:flutter/material.dart'; + +// Package imports +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:html/parser.dart'; +import 'package:lemmy_api_client/v3.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:thunder/comment/utils/comment.dart'; +import 'package:thunder/main.dart'; +import 'package:unifiedpush/unifiedpush.dart'; +import 'package:markdown/markdown.dart'; + +// Project imports +import 'package:thunder/account/models/account.dart'; +import 'package:thunder/core/enums/full_name.dart'; +import 'package:thunder/core/enums/local_settings.dart'; +import 'package:thunder/core/enums/notification_type.dart'; +import 'package:thunder/core/singletons/preferences.dart'; +import 'package:thunder/notification/shared/android_notification.dart'; +import 'package:thunder/notification/shared/notification_server.dart'; +import 'package:thunder/utils/global_context.dart'; +import 'package:thunder/utils/instance.dart'; + +/// Initializes push notifications for UnifiedPush. +/// For now, initializing UnifiedPush will enable push notifications for all accounts active on the app. +/// +/// The [controller] is passed in so that we can react to push notifications when the user taps on the notification. +void initUnifiedPushNotifications({required StreamController controller}) async { + UnifiedPush.initialize( + onNewEndpoint: (String endpoint, String instance) async { + debugPrint("Connected to new UnifiedPush endpoint: $instance @ $endpoint"); + + List accounts = await Account.accounts(); + + // We should remove any previously sent tokens, and send them again + bool removed = await deleteAccountFromNotificationServer(); + if (!removed) debugPrint("Failed to delete previous device token from server."); + + // TODO: Select accounts to enable push notifications + for (Account account in accounts) { + bool success = await sendAuthTokenToNotificationServer(type: NotificationType.unifiedPush, token: endpoint, jwts: [account.jwt!], instance: account.instance!); + if (!success) debugPrint("Failed to send device token to server for account ${account.id}. Skipping."); + } + }, + onRegistrationFailed: (String instance) async { + debugPrint("UnifiedPush registration failed for $instance"); + + // We should remove any previously sent tokens, and send them again + bool removed = await deleteAccountFromNotificationServer(); + if (!removed) debugPrint("Failed to delete previous device token from server."); + }, + onUnregistered: (String instance) async { + debugPrint("UnifiedPush unregistered from $instance"); + + // We should remove any previously sent tokens, and send them again + bool removed = await deleteAccountFromNotificationServer(); + if (!removed) debugPrint("Failed to delete previous device token from server."); + }, + onMessage: (Uint8List message, String instance) async { + // Ensure that the db is initialized before attempting to access below. + await initializeDatabase(); + + final SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; + final FullNameSeparator userSeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.userFormat.name) ?? FullNameSeparator.at.name); + final FullNameSeparator communitySeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.communityFormat.name) ?? FullNameSeparator.dot.name); + + Map data = jsonDecode(utf8.decode(message)); + + // Notification for replies + if (data.containsKey('reply')) { + CommentReplyView commentReplyView = CommentReplyView.fromJson(data['reply']); + + final String commentContent = cleanCommentContent(commentReplyView.comment); + final String htmlComment = markdownToHtml(commentContent); + final String plaintextComment = parse(parse(htmlComment).body?.text).documentElement?.text ?? commentContent; + + final BigTextStyleInformation bigTextStyleInformation = BigTextStyleInformation( + '${commentReplyView.post.name} · ${generateCommunityFullName(null, commentReplyView.community.name, fetchInstanceNameFromUrl(commentReplyView.community.actorId), communitySeparator: communitySeparator)}\n$htmlComment', + contentTitle: generateUserFullName(null, commentReplyView.creator.name, fetchInstanceNameFromUrl(commentReplyView.creator.actorId), userSeparator: userSeparator), + summaryText: generateUserFullName(null, commentReplyView.recipient.name, fetchInstanceNameFromUrl(commentReplyView.recipient.actorId), userSeparator: userSeparator), + htmlFormatBigText: true, + ); + + List accounts = await Account.accounts(); + Account account = accounts.firstWhere((Account account) => account.username == commentReplyView.recipient.name); + + showAndroidNotification( + id: commentReplyView.commentReply.id, + account: account, + bigTextStyleInformation: bigTextStyleInformation, + title: generateUserFullName(null, commentReplyView.creator.name, fetchInstanceNameFromUrl(commentReplyView.creator.actorId), userSeparator: userSeparator), + content: plaintextComment, + payload: '$repliesGroupKey-${commentReplyView.comment.id}', + summaryText: generateUserFullName(null, commentReplyView.recipient.name, fetchInstanceNameFromUrl(commentReplyView.recipient.actorId), userSeparator: userSeparator), + ); + } + + // Notification for a mention + if (data.containsKey('mention')) { + PersonMentionView personMentionView = PersonMentionView.fromJson(data['mention']); + + final String commentContent = cleanCommentContent(personMentionView.comment); + final String htmlComment = markdownToHtml(commentContent); + final String plaintextComment = parse(parse(htmlComment).body?.text).documentElement?.text ?? commentContent; + + final BigTextStyleInformation bigTextStyleInformation = BigTextStyleInformation( + '${personMentionView.post.name} · ${generateCommunityFullName(null, personMentionView.community.name, fetchInstanceNameFromUrl(personMentionView.community.actorId), communitySeparator: communitySeparator)}\n$htmlComment', + contentTitle: generateUserFullName(null, personMentionView.creator.name, fetchInstanceNameFromUrl(personMentionView.creator.actorId), userSeparator: userSeparator), + summaryText: generateUserFullName(null, personMentionView.recipient.name, fetchInstanceNameFromUrl(personMentionView.recipient.actorId), userSeparator: userSeparator), + htmlFormatBigText: true, + ); + + List accounts = await Account.accounts(); + Account account = accounts.firstWhere((Account account) => account.username == personMentionView.recipient.name); + + showAndroidNotification( + id: personMentionView.comment.id, + account: account, + bigTextStyleInformation: bigTextStyleInformation, + title: generateUserFullName(null, personMentionView.creator.name, fetchInstanceNameFromUrl(personMentionView.creator.actorId), userSeparator: userSeparator), + content: plaintextComment, + payload: '$repliesGroupKey-${personMentionView.comment.id}', + summaryText: generateUserFullName(null, personMentionView.recipient.name, fetchInstanceNameFromUrl(personMentionView.recipient.actorId), userSeparator: userSeparator), + ); + } + + if (data.containsKey('message')) { + // TODO: Show message + } + }, + ); + + // Register Thunder with UnifiedPush + if (GlobalContext.context.mounted) UnifiedPush.registerAppWithDialog(GlobalContext.context, 'Thunder', []); +} diff --git a/lib/settings/pages/general_settings_page.dart b/lib/settings/pages/general_settings_page.dart index d1fe93621..adbe10b6c 100644 --- a/lib/settings/pages/general_settings_page.dart +++ b/lib/settings/pages/general_settings_page.dart @@ -12,6 +12,7 @@ import 'package:android_intent_plus/android_intent.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:thunder/account/models/account.dart'; import 'package:thunder/core/enums/browser_mode.dart'; import 'package:thunder/core/enums/image_caching_mode.dart'; @@ -19,6 +20,7 @@ import 'package:thunder/core/enums/local_settings.dart'; import 'package:thunder/core/enums/notification_type.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/core/singletons/preferences.dart'; +import 'package:thunder/notification/utils/local_notifications.dart'; import 'package:thunder/settings/widgets/list_option.dart'; import 'package:thunder/settings/widgets/settings_list_tile.dart'; import 'package:thunder/settings/widgets/toggle_option.dart'; @@ -32,8 +34,7 @@ import 'package:thunder/utils/bottom_sheet_list_picker.dart'; import 'package:thunder/utils/constants.dart'; import 'package:thunder/utils/language/language.dart'; import 'package:thunder/utils/links.dart'; -import 'package:thunder/utils/notifications/notification_server.dart'; -import 'package:thunder/utils/notifications/notifications.dart'; +import 'package:thunder/notification/shared/notification_server.dart'; import 'package:unifiedpush/unifiedpush.dart'; import 'package:version/version.dart'; @@ -118,6 +119,9 @@ class _GeneralSettingsPageState extends State with SingleTi GlobalKey settingToHighlightKey = GlobalKey(); LocalSettings? settingToHighlight; + /// List of authenticated accounts + List accounts = []; + Future setPreferences(attribute, value) async { final prefs = (await UserPreferences.instance).sharedPreferences; @@ -220,6 +224,9 @@ class _GeneralSettingsPageState extends State with SingleTi void _initPreferences() async { final prefs = (await UserPreferences.instance).sharedPreferences; + // Get all accounts + List allAccounts = await Account.accounts(); + setState(() { // Default Sorts and Listing try { @@ -255,6 +262,8 @@ class _GeneralSettingsPageState extends State with SingleTi showInAppUpdateNotification = prefs.getBool(LocalSettings.showInAppUpdateNotification.name) ?? false; showUpdateChangelogs = prefs.getBool(LocalSettings.showUpdateChangelogs.name) ?? true; inboxNotificationType = NotificationType.values.byName(prefs.getString(LocalSettings.inboxNotificationType.name) ?? NotificationType.none.name); + + accounts = allAccounts; }); } @@ -660,8 +669,9 @@ class _GeneralSettingsPageState extends State with SingleTi SliverToBoxAdapter( child: ListOption( description: l10n.enableInboxNotifications, - subtitle: inboxNotificationType.toString(), + subtitle: accounts.isEmpty ? l10n.disabled : inboxNotificationType.toString(), value: const ListPickerItem(payload: -1), + disabled: accounts.isEmpty, icon: inboxNotificationType == NotificationType.none ? Icons.notifications_off_rounded : Icons.notifications_on_rounded, highlightKey: settingToHighlight == LocalSettings.inboxNotificationType ? settingToHighlightKey : null, customListPicker: StatefulBuilder( diff --git a/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart b/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart index cef8cf4bd..84da977d2 100644 --- a/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart +++ b/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart @@ -3,7 +3,8 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:thunder/utils/notifications/notifications.dart'; + +import '../../../notification/shared/android_notification.dart'; part 'notifications_state.dart'; diff --git a/lib/thunder/pages/thunder_page.dart b/lib/thunder/pages/thunder_page.dart index 20dd17dd7..12ddf6209 100644 --- a/lib/thunder/pages/thunder_page.dart +++ b/lib/thunder/pages/thunder_page.dart @@ -58,7 +58,7 @@ import 'package:thunder/comment/utils/navigate_comment.dart'; import 'package:thunder/post/utils/navigate_create_post.dart'; import 'package:thunder/instance/utils/navigate_instance.dart'; import 'package:thunder/post/utils/navigate_post.dart'; -import 'package:thunder/utils/notifications_navigation.dart'; +import 'package:thunder/notification/utils/navigate_notification.dart'; String? currentIntent; diff --git a/lib/utils/notifications.dart b/lib/utils/notifications.dart deleted file mode 100644 index 795480d9e..000000000 --- a/lib/utils/notifications.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:html/parser.dart'; -import 'package:lemmy_api_client/v3.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:markdown/markdown.dart'; - -import 'package:thunder/account/models/account.dart'; -import 'package:thunder/comment/utils/comment.dart'; -import 'package:thunder/core/auth/helpers/fetch_account.dart'; -import 'package:thunder/core/enums/full_name.dart'; -import 'package:thunder/core/enums/local_settings.dart'; -import 'package:thunder/core/enums/notification_type.dart'; -import 'package:thunder/core/singletons/lemmy_client.dart'; -import 'package:thunder/core/singletons/preferences.dart'; -import 'package:thunder/main.dart'; -import 'package:thunder/utils/instance.dart'; - -const String _inboxMessagesChannelId = 'inbox_messages'; -const String _inboxMessagesChannelName = 'Inbox Messages'; -const String repliesGroupKey = 'replies'; -const String _lastPollTimeId = 'thunder_last_notifications_poll_time'; -const int _repliesGroupSummaryId = 0; - -/// This method polls for new inbox messages and, if found, displays them as notificatons. -/// It is intended to be invoked from a background fetch task. -/// If the user has not configured inbox notifications, it will do nothing. -/// If no user is logged in, it will do nothing. -/// It will track when the last poll ran and ignore any inbox messages from before that time. -Future pollRepliesAndShowNotifications() async { - // This print statement is here for the sake of verifying that background checks only happen when they're supposed to. - // If we see this line outputted when notifications are disabled, then something is wrong - // with our configuration of background_fetch. - debugPrint('Thunder - Background fetch - Running notification poll'); - - final SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - final FullNameSeparator userSeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.userFormat.name) ?? FullNameSeparator.at.name); - final FullNameSeparator communitySeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.communityFormat.name) ?? FullNameSeparator.dot.name); - - // We shouldn't even come here if the setting is disabled, but just in case, exit. - if (NotificationType.values.byName(prefs.getString(LocalSettings.inboxNotificationType.name) ?? NotificationType.none.name) != NotificationType.local) return; - - // Ensure that the db is initialized before attempting to access below. - await initializeDatabase(); - - final Account? account = await fetchActiveProfileAccount(); - if (account == null) return; - - final DateTime lastPollTime = DateTime.tryParse(prefs.getString(_lastPollTimeId) ?? '') ?? DateTime.now(); - - // Iterate through inbox replies - // In the future, this could ALSO iterate among all saved accounts. - GetRepliesResponse getRepliesResponse = await LemmyClient.instance.lemmyApiV3.run( - GetReplies( - auth: account.jwt!, - unreadOnly: true, - limit: 50, // Max allowed by API - sort: CommentSortType.old, - page: 1, - ), - ); - - // Only handle messages that have arrived since the last time we polled - final Iterable newReplies = getRepliesResponse.replies.where((CommentReplyView commentReplyView) => commentReplyView.comment.published.isAfter(lastPollTime)); - - // For each message, generate a notification. - // On Android, put them in the same group. - for (final CommentReplyView commentReplyView in newReplies) { - // Format the comment body in a couple ways - final String commentContent = cleanCommentContent(commentReplyView.comment); - final String htmlComment = markdownToHtml(commentContent); - final String plaintextComment = parse(parse(htmlComment).body?.text).documentElement?.text ?? commentContent; - - final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - // Configure Android-specific settings - final BigTextStyleInformation bigTextStyleInformation = BigTextStyleInformation( - '${commentReplyView.post.name} · ${generateCommunityFullName(null, commentReplyView.community.name, fetchInstanceNameFromUrl(commentReplyView.community.actorId), communitySeparator: communitySeparator)}\n$htmlComment', - contentTitle: generateUserFullName(null, commentReplyView.creator.name, fetchInstanceNameFromUrl(commentReplyView.creator.actorId), userSeparator: userSeparator), - summaryText: generateUserFullName(null, account.username, account.instance, userSeparator: userSeparator), - htmlFormatBigText: true, - ); - final AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails( - _inboxMessagesChannelId, - _inboxMessagesChannelName, - styleInformation: bigTextStyleInformation, - groupKey: repliesGroupKey, - ); - final NotificationDetails notificationDetails = NotificationDetails(android: androidNotificationDetails); - - // Show the notification! - await flutterLocalNotificationsPlugin.show( - // This is the notification ID, which should be unique. - // In the future it might need to incorporate user/instance id - // to avoid comment id collisions. - commentReplyView.comment.id, - // Title (username of sender) - generateUserFullName(null, commentReplyView.creator.name, fetchInstanceNameFromUrl(commentReplyView.creator.actorId), userSeparator: userSeparator), - // Body (body of comment) - plaintextComment, - notificationDetails, - payload: '$repliesGroupKey-${commentReplyView.commentReply.id}', - ); - - // Create a summary notification for the group. - // Note that it's ok to create this for every message, because it has a fixed ID, - // so it will just get 'updated'. - final InboxStyleInformation inboxStyleInformationSummary = - InboxStyleInformation([], contentTitle: '', summaryText: generateUserFullName(null, account.username, account.instance, userSeparator: userSeparator)); - final AndroidNotificationDetails androidNotificationDetailsSummary = AndroidNotificationDetails( - _inboxMessagesChannelId, - _inboxMessagesChannelName, - styleInformation: inboxStyleInformationSummary, - groupKey: repliesGroupKey, - setAsGroupSummary: true, - ); - final NotificationDetails notificationDetailsSummary = NotificationDetails(android: androidNotificationDetailsSummary); - - // Send the summary message! - await flutterLocalNotificationsPlugin.show( - _repliesGroupSummaryId, - '', - '', - notificationDetailsSummary, - payload: repliesGroupKey, - ); - } - - // Save our poll time - prefs.setString(_lastPollTimeId, DateTime.now().toString()); -} diff --git a/lib/utils/notifications/notifications.dart b/lib/utils/notifications/notifications.dart deleted file mode 100644 index 29120221a..000000000 --- a/lib/utils/notifications/notifications.dart +++ /dev/null @@ -1,368 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:background_fetch/background_fetch.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:html/parser.dart'; -import 'package:lemmy_api_client/v3.dart'; -import 'package:push/push.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:markdown/markdown.dart'; - -import 'package:thunder/account/models/account.dart'; -import 'package:thunder/core/auth/helpers/fetch_account.dart'; -import 'package:thunder/core/enums/full_name.dart'; -import 'package:thunder/core/enums/local_settings.dart'; -import 'package:thunder/core/enums/notification_type.dart'; -import 'package:thunder/core/singletons/lemmy_client.dart'; -import 'package:thunder/core/singletons/preferences.dart'; -import 'package:thunder/utils/global_context.dart'; -import 'package:thunder/utils/instance.dart'; -import 'package:thunder/utils/notifications/notification_server.dart'; -import 'package:unifiedpush/unifiedpush.dart'; - -const String _inboxMessagesChannelId = 'inbox_messages'; -const String _inboxMessagesChannelName = 'Inbox Messages'; -const String repliesGroupKey = 'replies'; -const String _lastPollTimeId = 'thunder_last_notifications_poll_time'; -const int _repliesGroupSummaryId = 0; - -/// Displays a push notification on Android -void showAndroidNotification({ - required int id, - required BigTextStyleInformation bigTextStyleInformation, - String title = '', - String content = '', - String payload = '', - String summaryText = '', -}) async { - final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - - // Configure Android-specific settings - final AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails( - _inboxMessagesChannelId, - _inboxMessagesChannelName, - styleInformation: bigTextStyleInformation, - groupKey: repliesGroupKey, - ); - - final NotificationDetails notificationDetails = NotificationDetails(android: androidNotificationDetails); - - // Show the notification! - await flutterLocalNotificationsPlugin.show(id, title, content, notificationDetails, payload: payload); - - // Create a summary notification for the group. - // Note that it's ok to create this for every message, because it has a fixed ID, so it will just get 'updated'. - final InboxStyleInformation inboxStyleInformationSummary = InboxStyleInformation( - [], - contentTitle: '', - summaryText: summaryText, - ); - - final AndroidNotificationDetails androidNotificationDetailsSummary = AndroidNotificationDetails( - _inboxMessagesChannelId, - _inboxMessagesChannelName, - styleInformation: inboxStyleInformationSummary, - groupKey: repliesGroupKey, - setAsGroupSummary: true, - ); - - final NotificationDetails notificationDetailsSummary = NotificationDetails(android: androidNotificationDetailsSummary); - - // Send the summary message! - await flutterLocalNotificationsPlugin.show( - _repliesGroupSummaryId, - '', - '', - notificationDetailsSummary, - payload: repliesGroupKey, - ); -} - -/// The main function which triggers push notification logic. This handles both Android and iOS. -/// -/// The [controller] is passed in so that we can react to push notifications. -Future initPushNotificationLogic({required StreamController controller}) async { - if (Platform.isAndroid) { - initAndroidPushNotificationLogic(controller: controller); - } else if (Platform.isIOS) { - initIOSPushNotificationLogic(controller: controller); - } -} - -/// Initialize Android specific notification logic. This is only called when the app is running on Android. -void initAndroidPushNotificationLogic({required StreamController controller}) async { - SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - NotificationType notificationType = NotificationType.values.byName(prefs.getString(LocalSettings.inboxNotificationType.name) ?? NotificationType.none.name); - - // Return if we don't have the expected types - if (!(notificationType != NotificationType.local || notificationType != NotificationType.unifiedPush)) return; - - if (notificationType == NotificationType.unifiedPush) { - UnifiedPush.initialize( - onNewEndpoint: (String endpoint, String instance) async { - debugPrint("Connected to instance: $instance @ $endpoint"); - List accounts = await Account.accounts(); - - for (Account account in accounts) { - // TODO: Select accounts to enable push notifications - bool success = await sendAuthTokenToNotificationServer(type: notificationType, token: endpoint, jwts: [account.jwt!], instance: account.instance!); - } - }, - onRegistrationFailed: (String instance) { - debugPrint("UnifiedPush registration failed for $instance"); - }, - onUnregistered: (String instance) { - debugPrint("UnifiedPush unregistered from $instance"); - }, - onMessage: (Uint8List message, String instance) { - Map data = jsonDecode(utf8.decode(message)); - Map metadata = data['metadata']; - - final FullNameSeparator userSeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.userFormat.name) ?? FullNameSeparator.at.name); - final FullNameSeparator communitySeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.communityFormat.name) ?? FullNameSeparator.dot.name); - - final BigTextStyleInformation bigTextStyleInformation = BigTextStyleInformation( - '${metadata['post']['title']} · ${generateCommunityFullName(null, metadata['community']['name'], fetchInstanceNameFromUrl(metadata['community']['instance']), communitySeparator: communitySeparator)}\n$message', - contentTitle: generateUserFullName(null, metadata['creator']['name'], fetchInstanceNameFromUrl(metadata['creator']['instance']), userSeparator: userSeparator), - summaryText: generateUserFullName(null, metadata['account']['name'], metadata['account']['instance'], userSeparator: userSeparator), - htmlFormatBigText: true, - ); - - final String summaryText = generateUserFullName( - null, - metadata['account']['name'], - metadata['account']['instance'], - userSeparator: userSeparator, - ); - - showAndroidNotification( - id: 1, - title: data['title'] ?? "", - content: data['message'] ?? "", - payload: '$repliesGroupKey-${data['metadata']['id']}', - summaryText: summaryText, - bigTextStyleInformation: bigTextStyleInformation, - ); - }, - ); - - // Register Thunder with UnifiedPush - if (GlobalContext.context.mounted) UnifiedPush.registerAppWithDialog(GlobalContext.context, 'Thunder', []); - } else if (notificationType == NotificationType.local) { - // Initialize background fetch (this is async and can go run on its own). - initBackgroundFetch(); - - // Register to receive BackgroundFetch events after app is terminated. - initHeadlessBackgroundFetch(); - } - - // Initialize the Flutter Local Notifications plugin for both UnifiedPush and Local notifications - FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - - // Initialize the Android-specific settings, using the splash asset as the notification icon. - const AndroidInitializationSettings initializationSettingsAndroid = AndroidInitializationSettings('splash'); - const InitializationSettings initializationSettings = InitializationSettings(android: initializationSettingsAndroid); - - await flutterLocalNotificationsPlugin.initialize( - initializationSettings, - onDidReceiveNotificationResponse: (notificationResponse) => controller.add(notificationResponse), - ); - - // See if Thunder is launching because a notification was tapped. If so, we want to jump right to the appropriate page. - final NotificationAppLaunchDetails? notificationAppLaunchDetails = await FlutterLocalNotificationsPlugin().getNotificationAppLaunchDetails(); - - if (notificationAppLaunchDetails?.didNotificationLaunchApp == true && notificationAppLaunchDetails?.notificationResponse != null) { - controller.add(notificationAppLaunchDetails!.notificationResponse!); - - bool startupDueToGroupNotification = notificationAppLaunchDetails.notificationResponse!.payload == repliesGroupKey; - // Do a notifications check on startup, if the user isn't clicking on a group notification - if (!startupDueToGroupNotification && notificationType == NotificationType.local) pollRepliesAndShowNotifications(); - } -} - -/// Initialize iOS specific notification logic. This is only called when the app is running on iOS. -void initIOSPushNotificationLogic({required StreamController controller}) async { - SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - NotificationType notificationType = NotificationType.values.byName(prefs.getString(LocalSettings.inboxNotificationType.name) ?? NotificationType.none.name); - - // Return if we don't have the expected types - if (notificationType != NotificationType.apn) return; - - // Fetch device token for APNs - String? token = await Push.instance.token; - if (token == null) return; - - List accounts = await Account.accounts(); - - /// We need to send this device token along with the jwt so that the server can poll for new notifications and send them to this device. - debugPrint("Device token: $token"); - - for (Account account in accounts) { - // TODO: Select accounts to enable push notifications - bool success = await sendAuthTokenToNotificationServer(type: notificationType, token: token, jwts: [account.jwt!], instance: account.instance!); - } - - // Handle new tokens generated from the device - Push.instance.onNewToken.listen((token) async { - /// We need to send this device token along with the jwt so that the server can poll for new notifications and send them to this device. - debugPrint("Received new device token: $token"); - - for (Account account in accounts) { - // TODO: Select accounts to enable push notifications - bool success = await sendAuthTokenToNotificationServer(type: notificationType, token: token, jwts: [account.jwt!], instance: account.instance!); - } - }); - - // Handle notification launching app from terminated state - Push.instance.notificationTapWhichLaunchedAppFromTerminated.then((data) { - if (data == null) return; - - debugPrint('Notification was tapped notificationTapWhichLaunchedAppFromTerminated: Data: $data \n'); - if (data.containsKey(repliesGroupKey)) { - controller.add(NotificationResponse(payload: data[repliesGroupKey] as String, notificationResponseType: NotificationResponseType.selectedNotification)); - } - }); - - /// Handle notification taps. This triggers when the user taps on a notification when the app is on the foreground or background. - Push.instance.onNotificationTap.listen((data) { - debugPrint('Notification was tapped onNotificationTap: Data: $data \n'); - - if (data.containsKey(repliesGroupKey)) { - controller.add(NotificationResponse(payload: data[repliesGroupKey] as String, notificationResponseType: NotificationResponseType.selectedNotification)); - } - }); -} - -// ---------------- ANDROID LOCAL NOTIFICATIONS FETCH LOGIC ---------------- // - -/// This method polls for new inbox messages and, if found, displays them as notificatons. -/// It is intended to be invoked from a background fetch task. -/// If the user has not configured inbox notifications, it will do nothing. -/// If no user is logged in, it will do nothing. -/// It will track when the last poll ran and ignore any inbox messages from before that time. -Future pollRepliesAndShowNotifications() async { - // This print statement is here for the sake of verifying that background checks only happen when they're supposed to. - // If we see this line outputted when notifications are disabled, then something is wrong with our configuration of background_fetch. - debugPrint('Thunder - Background fetch - Running notification poll'); - - final Account? account = await fetchActiveProfileAccount(); - if (account == null) return; - - final SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - final FullNameSeparator userSeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.userFormat.name) ?? FullNameSeparator.at.name); - final FullNameSeparator communitySeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.communityFormat.name) ?? FullNameSeparator.dot.name); - - final DateTime lastPollTime = DateTime.tryParse(prefs.getString(_lastPollTimeId) ?? '') ?? DateTime.now(); - - // Iterate through inbox replies. This only fetches the current active account. - // TODO: Iterate through all saved accounts. - GetRepliesResponse getRepliesResponse = await LemmyClient.instance.lemmyApiV3.run( - GetReplies( - auth: account.jwt!, - unreadOnly: false, - limit: 50, // Max allowed by API - sort: CommentSortType.old, - page: 1, - ), - ); - - // Only handle messages that have arrived since the last time we polled - final Iterable newReplies = getRepliesResponse.replies.where((CommentReplyView commentReplyView) => commentReplyView.comment.published.isAfter(lastPollTime)); - - // For each message, generate a notification. On Android, put them in the same group. - for (final CommentReplyView commentReplyView in newReplies) { - // Format the comment body in a couple ways - final String htmlComment = markdownToHtml(commentReplyView.comment.content); - final String plaintextComment = parse(parse(htmlComment).body?.text).documentElement?.text ?? commentReplyView.comment.content; - final String title = generateUserFullName(null, commentReplyView.creator.name, fetchInstanceNameFromUrl(commentReplyView.creator.actorId), userSeparator: userSeparator); - - final BigTextStyleInformation bigTextStyleInformation = BigTextStyleInformation( - '${commentReplyView.post.name} · ${generateCommunityFullName(null, commentReplyView.community.name, fetchInstanceNameFromUrl(commentReplyView.community.actorId), communitySeparator: communitySeparator)}\n$htmlComment', - contentTitle: generateUserFullName(null, commentReplyView.creator.name, fetchInstanceNameFromUrl(commentReplyView.creator.actorId), userSeparator: userSeparator), - summaryText: generateUserFullName(null, account.username, account.instance, userSeparator: userSeparator), - htmlFormatBigText: true, - ); - - showAndroidNotification( - id: commentReplyView.comment.id, - title: title, - content: plaintextComment, - bigTextStyleInformation: bigTextStyleInformation, - payload: '$repliesGroupKey-${commentReplyView.commentReply.id}', - summaryText: generateUserFullName( - null, - account.username, - account.instance, - userSeparator: userSeparator, - ), - ); - } - - // Save our poll time - prefs.setString(_lastPollTimeId, DateTime.now().toString()); -} - -// ---------------- ANDROID LOCAL NOTIFICATIONS BACKGROUND FETCH LOGIC ---------------- // - -/// This method handles "headless" callbacks (i.e., when the app is not running) -@pragma('vm:entry-point') -void backgroundFetchHeadlessTask(HeadlessTask task) async { - if (task.timeout) { - BackgroundFetch.finish(task.taskId); - return; - } - await pollRepliesAndShowNotifications(); - BackgroundFetch.finish(task.taskId); -} - -/// The method initializes background fetching while the app is running -Future initBackgroundFetch() async { - await BackgroundFetch.configure( - BackgroundFetchConfig( - minimumFetchInterval: 15, - stopOnTerminate: false, - startOnBoot: true, - enableHeadless: true, - requiredNetworkType: NetworkType.NONE, - requiresBatteryNotLow: false, - requiresStorageNotLow: false, - requiresCharging: false, - requiresDeviceIdle: false, - // Uncomment this line (and set the minimumFetchInterval to 1) for quicker testing. - // forceAlarmManager: true, - ), - // This is the callback that handles background fetching while the app is running. - (String taskId) async { - await pollRepliesAndShowNotifications(); - BackgroundFetch.finish(taskId); - }, - // This is the timeout callback. - (String taskId) async { - BackgroundFetch.finish(taskId); - }, - ); -} - -void disableBackgroundFetch() async { - await BackgroundFetch.configure( - BackgroundFetchConfig( - minimumFetchInterval: 15, - stopOnTerminate: true, - startOnBoot: false, - enableHeadless: false, - ), - () {}, - () {}, - ); -} - -// This method initializes background fetching while the app is not running -void initHeadlessBackgroundFetch() async { - BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask); -} - -// ---------------- END BACKGROUND FETCH STUFF ---------------- // From c84e92045cf07550739a79663206a9673b71be0f Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Fri, 19 Apr 2024 18:08:36 -0700 Subject: [PATCH 10/18] moved notification_type enum --- lib/main.dart | 2 +- lib/{core => notification}/enums/notification_type.dart | 0 lib/notification/notifications.dart | 2 +- lib/notification/shared/notification_server.dart | 2 +- lib/notification/utils/apns.dart | 2 +- lib/notification/utils/unified_push.dart | 2 +- lib/settings/pages/general_settings_page.dart | 2 +- lib/thunder/bloc/thunder_bloc.dart | 2 +- lib/utils/preferences.dart | 2 +- 9 files changed, 8 insertions(+), 8 deletions(-) rename lib/{core => notification}/enums/notification_type.dart (100%) diff --git a/lib/main.dart b/lib/main.dart index 293765806..1f2763d77 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,7 +30,7 @@ import 'package:thunder/core/auth/bloc/auth_bloc.dart'; import 'package:thunder/core/database/database.dart'; import 'package:thunder/core/database/migrations.dart'; import 'package:thunder/core/enums/local_settings.dart'; -import 'package:thunder/core/enums/notification_type.dart'; +import 'package:thunder/notification/enums/notification_type.dart'; import 'package:thunder/core/enums/theme_type.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/core/singletons/preferences.dart'; diff --git a/lib/core/enums/notification_type.dart b/lib/notification/enums/notification_type.dart similarity index 100% rename from lib/core/enums/notification_type.dart rename to lib/notification/enums/notification_type.dart diff --git a/lib/notification/notifications.dart b/lib/notification/notifications.dart index 86f1d699c..47746b738 100644 --- a/lib/notification/notifications.dart +++ b/lib/notification/notifications.dart @@ -10,7 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart'; // Project imports import 'package:thunder/core/enums/local_settings.dart'; -import 'package:thunder/core/enums/notification_type.dart'; +import 'package:thunder/notification/enums/notification_type.dart'; import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/notification/shared/android_notification.dart'; import 'package:thunder/notification/utils/apns.dart'; diff --git a/lib/notification/shared/notification_server.dart b/lib/notification/shared/notification_server.dart index a4abe6e1c..c42264467 100644 --- a/lib/notification/shared/notification_server.dart +++ b/lib/notification/shared/notification_server.dart @@ -9,7 +9,7 @@ import 'package:http/http.dart' as http; // Project imports: import 'package:thunder/account/models/account.dart'; -import 'package:thunder/core/enums/notification_type.dart'; +import 'package:thunder/notification/enums/notification_type.dart'; import 'package:thunder/utils/constants.dart'; // String notificationServerUrl = '$THUNDER_SERVER_URL/notifications'; diff --git a/lib/notification/utils/apns.dart b/lib/notification/utils/apns.dart index 22bec2e83..a8b19f716 100644 --- a/lib/notification/utils/apns.dart +++ b/lib/notification/utils/apns.dart @@ -10,7 +10,7 @@ import 'package:push/push.dart'; // Project imports import 'package:thunder/account/models/account.dart'; -import 'package:thunder/core/enums/notification_type.dart'; +import 'package:thunder/notification/enums/notification_type.dart'; import 'package:thunder/notification/shared/notification_server.dart'; /// Initializes push notifications for APNs (Apple Push Notifications service). diff --git a/lib/notification/utils/unified_push.dart b/lib/notification/utils/unified_push.dart index caa0fa1f7..108139053 100644 --- a/lib/notification/utils/unified_push.dart +++ b/lib/notification/utils/unified_push.dart @@ -20,7 +20,7 @@ import 'package:markdown/markdown.dart'; import 'package:thunder/account/models/account.dart'; import 'package:thunder/core/enums/full_name.dart'; import 'package:thunder/core/enums/local_settings.dart'; -import 'package:thunder/core/enums/notification_type.dart'; +import 'package:thunder/notification/enums/notification_type.dart'; import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/notification/shared/android_notification.dart'; import 'package:thunder/notification/shared/notification_server.dart'; diff --git a/lib/settings/pages/general_settings_page.dart b/lib/settings/pages/general_settings_page.dart index adbe10b6c..62772f053 100644 --- a/lib/settings/pages/general_settings_page.dart +++ b/lib/settings/pages/general_settings_page.dart @@ -17,7 +17,7 @@ import 'package:thunder/core/enums/browser_mode.dart'; import 'package:thunder/core/enums/image_caching_mode.dart'; import 'package:thunder/core/enums/local_settings.dart'; -import 'package:thunder/core/enums/notification_type.dart'; +import 'package:thunder/notification/enums/notification_type.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/notification/utils/local_notifications.dart'; diff --git a/lib/thunder/bloc/thunder_bloc.dart b/lib/thunder/bloc/thunder_bloc.dart index 929b4962f..35372e8bf 100644 --- a/lib/thunder/bloc/thunder_bloc.dart +++ b/lib/thunder/bloc/thunder_bloc.dart @@ -18,7 +18,7 @@ import 'package:thunder/core/enums/full_name.dart'; import 'package:thunder/core/enums/image_caching_mode.dart'; import 'package:thunder/core/enums/local_settings.dart'; import 'package:thunder/core/enums/nested_comment_indicator.dart'; -import 'package:thunder/core/enums/notification_type.dart'; +import 'package:thunder/notification/enums/notification_type.dart'; import 'package:thunder/core/enums/post_body_view_type.dart'; import 'package:thunder/core/enums/swipe_action.dart'; import 'package:thunder/core/enums/theme_type.dart'; diff --git a/lib/utils/preferences.dart b/lib/utils/preferences.dart index 485c52f9f..5abfde13b 100644 --- a/lib/utils/preferences.dart +++ b/lib/utils/preferences.dart @@ -2,7 +2,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:thunder/core/enums/browser_mode.dart'; import 'package:thunder/core/enums/full_name.dart'; import 'package:thunder/core/enums/local_settings.dart'; -import 'package:thunder/core/enums/notification_type.dart'; +import 'package:thunder/notification/enums/notification_type.dart'; import 'package:thunder/core/singletons/preferences.dart'; Future performSharedPreferencesMigration() async { From 83c497089caec58e461d5fd9c058976ebc788468 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Fri, 19 Apr 2024 18:10:34 -0700 Subject: [PATCH 11/18] fixed localization sorting --- lib/l10n/app_en.arb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e55445a6d..2b6b4b650 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -507,6 +507,10 @@ "@disablePushNotifications": { "description": "Description when disabling push notifications" }, + "disabled": "Disabled", + "@disabled": { + "description": "Describes the state of something that is disabled" + }, "discussionLanguages": "Discussion Languages", "@discussionLanguages": { "description": "Only load posts and communities in your language(s)" @@ -2078,6 +2082,5 @@ "description": "The total score of post or comment" }, "xUpvotes": "{x} upvotes", - "@xUpvotes": {}, - "disabled": "Disabled" -} + "@xUpvotes": {} +} \ No newline at end of file From d790902c3a8624dad083df9941c9619a75b83dd1 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Fri, 19 Apr 2024 18:12:48 -0700 Subject: [PATCH 12/18] reverted pubspec.lock --- pubspec.lock | 148 +++++++++++++++++++++++++-------------------------- 1 file changed, 74 insertions(+), 74 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 7475b7fde..4f82a34e6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: "direct main" description: name: android_intent_plus - sha256: "2bfdbee8d65e7c26f88b66f0a91f2863da4d3596d8a658b4162c8de5cf04b074" + sha256: e92d14009f3f6ebafca6a601958aaebb793559fb03a1961fe3c5596db95af2cb url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "5.0.1" ansicolor: dependency: transitive description: @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.4.2" async: dependency: transitive description: @@ -157,10 +157,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "3ac61a79bfb6f6cc11f693591063a7f19a7af628dc52f141743edac5c16e8c22" + sha256: "581bacf68f89ec8792f5e5a0b2c4decd1c948e97ce659dc783688c8a88fbec21" url: "https://pub.dev" source: hosted - version: "2.4.9" + version: "2.4.8" build_runner_core: dependency: transitive description: @@ -181,10 +181,10 @@ packages: dependency: transitive description: name: built_value - sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb + sha256: fedde275e0a6b798c3296963c5cd224e3e1b55d0e478d5b7e65e6b540f363a0e url: "https://pub.dev" source: hosted - version: "8.9.2" + version: "8.9.1" cached_network_image: dependency: "direct main" description: @@ -309,10 +309,10 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.0.6" dart_ping: dependency: "direct main" description: @@ -357,18 +357,18 @@ packages: dependency: transitive description: name: dev_build - sha256: "2fa3bf81b5e92504f8d7a412df2c69b61a24ceca468466e5e777cbb59c30af96" + sha256: e5d575f3de4b0e5f004e065e1e2d98fa012d634b61b5855216b5698ed7f1e443 url: "https://pub.dev" source: hosted - version: "0.16.5" + version: "0.16.4+3" device_info_plus: dependency: "direct main" description: name: device_info_plus - sha256: eead12d1a1ed83d8283ab4c2f3fca23ac4082f29f25f29dff0f758f57d06ec91 + sha256: "50fb435ed30c6d2525cbfaaa0f46851ea6131315f213c0d921b0e407b34e3b84" url: "https://pub.dev" source: hosted - version: "10.1.0" + version: "10.0.1" device_info_plus_platform_interface: dependency: transitive description: @@ -381,10 +381,10 @@ packages: dependency: "direct main" description: name: dio - sha256: "11e40df547d418cc0c4900a9318b26304e665da6fa4755399a9ff9efd09034b5" + sha256: "0978e9a3e45305a80a7210dbeaf79d6ee8bee33f70c8e542dc654c952070217f" url: "https://pub.dev" source: hosted - version: "5.4.3+1" + version: "5.4.2+1" drift: dependency: "direct main" description: @@ -445,10 +445,10 @@ packages: dependency: transitive description: name: extended_image_library - sha256: c9caee8fe9b6547bd41c960c4f2d1ef8e34321804de6a1777f1d614a24247ad6 + sha256: d9a3675485bd69fe1bbe3b7f5664a3544d3eb518adb5a18dab05557562ae1395 url: "https://pub.dev" source: hosted - version: "4.0.4" + version: "4.0.3" fading_edge_scrollview: dependency: "direct main" description: @@ -533,10 +533,10 @@ packages: dependency: transitive description: name: flex_seed_scheme - sha256: "4cee2f1d07259f77e8b36f4ec5f35499d19e74e17c7dce5b819554914082bc01" + sha256: "29c12aba221eb8a368a119685371381f8035011d18de5ba277ad11d7dfb8657f" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.4.0" flutter: dependency: "direct main" description: flutter @@ -699,10 +699,10 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: a701df4866f9a38bb8e4450a54c143bbeeb0ce2381e7df5a36e1006f3b43bb28 + sha256: f9a05409385b77b06c18f200a41c7c2711ebf7415669350bb0f8474c07bd40d1 url: "https://pub.dev" source: hosted - version: "17.0.1" + version: "17.0.0" flutter_local_notifications_linux: dependency: transitive description: @@ -728,10 +728,10 @@ packages: dependency: "direct main" description: name: flutter_markdown - sha256: "04c4722cc36ec5af38acc38ece70d22d3c2123c61305d555750a091517bbe504" + sha256: "87e11b9df25a42e2db315b8b7a51fae8e66f57a4b2f50ec4b822d0fa155e6b52" url: "https://pub.dev" source: hosted - version: "0.6.23" + version: "0.6.22" flutter_native_splash: dependency: "direct main" description: @@ -744,10 +744,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "8cf40eebf5dec866a6d1956ad7b4f7016e6c0cc69847ab946833b7d43743809f" + sha256: b068ffc46f82a55844acfa4fdbb61fad72fa2aef0905548419d97f0f95c456da url: "https://pub.dev" source: hosted - version: "2.0.19" + version: "2.0.17" flutter_sharing_intent: dependency: "direct main" description: @@ -794,10 +794,10 @@ packages: dependency: transitive description: name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "3.2.0" gal: dependency: "direct main" description: @@ -818,10 +818,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: "771c8feb40ad0ef639973d7ecf1b43d55ffcedb2207fd43fab030f5639e40446" + sha256: "7ecb2f391edbca5473db591b48555a8912dde60edd0fb3013bd6743033b2d3f8" url: "https://pub.dev" source: hosted - version: "13.2.4" + version: "13.2.1" graphs: dependency: transitive description: @@ -890,34 +890,34 @@ packages: dependency: "direct main" description: name: image_picker - sha256: fe9ee64ccb8d599a5dfb0e21cc6652232c610bcf667af4e79b9eb175cc30a7a5 + sha256: "26222b01a0c9a2c8fe02fc90b8208bd3325da5ed1f4a2acabf75939031ac0bdd" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.0.7" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: "8e75431a62b7feb4fd55cb4a5c6f0ac4564460ec5dc09f9c4a0d50a5ce7c4cb9" + sha256: "39f2bfe497e495450c81abcd44b62f56c2a36a37a175da7d137b4454977b51b1" url: "https://pub.dev" source: hosted - version: "0.8.10" + version: "0.8.9+3" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: "5d6eb13048cd47b60dbf1a5495424dea226c5faf3950e20bf8120a58efb5b5f3" + sha256: e2423c53a68b579a7c37a1eda967b8ae536c3d98518e5db95ca1fe5719a730a3 url: "https://pub.dev" source: hosted - version: "3.0.4" + version: "3.0.2" image_picker_ios: dependency: transitive description: name: image_picker_ios - sha256: f4a6f62be96d6fd268f32a6bf8ef444cd8e3fff64d16923c6e6fe55e0c84a761 + sha256: "917a5cadd67d052554cfb258595e54217de53fac5b52939426e26319a02e6297" url: "https://pub.dev" source: hosted - version: "0.8.10" + version: "0.8.9+2" image_picker_linux: dependency: transitive description: @@ -938,10 +938,10 @@ packages: dependency: transitive description: name: image_picker_platform_interface - sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" + sha256: "3d2c323daea9d60608f1caf30be32a938916f4975434b8352e6f73dae496da38" url: "https://pub.dev" source: hosted - version: "2.10.0" + version: "2.9.4" image_picker_windows: dependency: transitive description: @@ -986,10 +986,10 @@ packages: dependency: transitive description: name: js - sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.6.7" json_annotation: dependency: transitive description: @@ -1189,18 +1189,18 @@ packages: dependency: "direct main" description: name: path_provider - sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 + sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.2" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d + sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.2.2" path_provider_foundation: dependency: transitive description: @@ -1349,10 +1349,10 @@ packages: dependency: transitive description: name: pointycastle - sha256: "70fe966348fe08c34bf929582f1d8247d9d9408130723206472b4687227e4333" + sha256: "43ac87de6e10afabc85c445745a7b799e04de84cebaa4fd7bf55a5e1e9604d29" url: "https://pub.dev" source: hosted - version: "3.8.0" + version: "3.7.4" pool: dependency: transitive description: @@ -1480,10 +1480,10 @@ packages: dependency: "direct main" description: name: share_plus - sha256: fb5319f3aab4c5dda5ebb92dca978179ba21f8c783ee4380910ef4c1c6824f51 + sha256: "05ec043470319bfbabe0adbc90d3a84cbff0426b9d9f3a6e2ad3e131fa5fa629" url: "https://pub.dev" source: hosted - version: "8.0.3" + version: "8.0.2" share_plus_platform_interface: dependency: transitive description: @@ -1496,18 +1496,18 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 + sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.2.2" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2" + sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.1" shared_preferences_foundation: dependency: transitive description: @@ -1613,10 +1613,10 @@ packages: dependency: "direct main" description: name: sqflite - sha256: "5ce2e1a15e822c3b4bfb5400455775e421da7098eed8adc8f26298ada7c9308c" + sha256: a9016f495c927cb90557c909ff26a6d92d9bd54fc42ba92e19d4e79d61e798c6 url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.3.2" sqflite_common: dependency: transitive description: @@ -1637,18 +1637,18 @@ packages: dependency: "direct main" description: name: sqflite_common_ffi_web - sha256: cfc9d1c61a3e06e5b2e96994a44b11125b4f451fee95b9fad8bd473b4613d592 + sha256: "323e6db355c9eac7d3d834e35be339cedbd04f43ef5bcef4e8158bd290c2a94e" url: "https://pub.dev" source: hosted - version: "0.4.3+1" + version: "0.4.3" sqlite3: dependency: transitive description: name: sqlite3 - sha256: "1abbeb84bf2b1a10e5e1138c913123c8aa9d83cd64e5f9a0dd847b3c83063202" + sha256: "072128763f1547e3e9b4735ce846bfd226d68019ccda54db4cd427b12dfdedc9" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.0" sqlite3_flutter_libs: dependency: "direct main" description: @@ -1821,18 +1821,18 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e" + sha256: "0ecc004c62fd3ed36a2ffcbe0dd9700aee63bd7532d0b642a488b1ec310f492e" url: "https://pub.dev" source: hosted - version: "6.2.6" + version: "6.2.5" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "360a6ed2027f18b73c8d98e159dda67a61b7f2e0f6ec26e86c3ada33b0621775" + sha256: d4ed0711849dd8e33eb2dd69c25db0d0d3fdc37e0a62e629fe32f57a22db2745 url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.3.0" url_launcher_ios: dependency: transitive description: @@ -1869,10 +1869,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" + sha256: "3692a459204a33e04bc94f5fb91158faf4f2c8903281ddd82915adecdb1a901d" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.0" url_launcher_windows: dependency: transitive description: @@ -1885,10 +1885,10 @@ packages: dependency: "direct main" description: name: uuid - sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8" + sha256: cd210a09f7c18cbe5a02511718e0334de6559871052c90a90c0cca46a4aa81c8 url: "https://pub.dev" source: hosted - version: "4.4.0" + version: "4.3.3" vector_math: dependency: transitive description: @@ -1941,10 +1941,10 @@ packages: dependency: transitive description: name: web_socket_channel - sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" + sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" url: "https://pub.dev" source: hosted - version: "2.4.5" + version: "2.4.4" webview_flutter: dependency: "direct main" description: @@ -1981,18 +1981,18 @@ packages: dependency: transitive description: name: win32 - sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a" + sha256: "8cb58b45c47dcb42ab3651533626161d6b67a2921917d8d429791f76972b3480" url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "5.3.0" win32_registry: dependency: transitive description: name: win32_registry - sha256: "10589e0d7f4e053f2c61023a31c9ce01146656a70b7b7f0828c0b46d7da2a9bb" + sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.1.2" xdg_directories: dependency: transitive description: From 0f4c663f72f3238220e3b097df777016fc3bbf57 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Sat, 20 Apr 2024 10:19:08 -0700 Subject: [PATCH 13/18] added setting to change notification server --- lib/core/enums/local_settings.dart | 2 + lib/l10n/app_en.arb | 6 +- lib/main.dart | 12 ++- .../shared/notification_server.dart | 21 +++-- lib/settings/pages/debug_settings_page.dart | 1 + lib/settings/pages/general_settings_page.dart | 86 ++++++++++++++++--- .../notifications_cubit.dart | 2 +- 7 files changed, 110 insertions(+), 20 deletions(-) diff --git a/lib/core/enums/local_settings.dart b/lib/core/enums/local_settings.dart index fa3bf9398..2ab0b5dab 100644 --- a/lib/core/enums/local_settings.dart +++ b/lib/core/enums/local_settings.dart @@ -102,6 +102,7 @@ enum LocalSettings { scoreCounters(name: 'setting_score_counters', key: "showScoreCounters", category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.feed), appLanguageCode(name: 'setting_app_language_code', key: 'appLanguage', category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.feedTypeAndSorts), inboxNotificationType(name: 'setting_inbox_notification_type', key: 'inboxNotificationType', category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.notifications), + pushNotificationServer(name: 'setting_push_notification_server', key: 'pushNotificationServer', category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.notifications), /// -------------------------- Feed Post Related Settings -------------------------- // Compact Related Settings @@ -323,6 +324,7 @@ extension LocalizationExt on AppLocalizations { 'showInAppUpdateNotifications': showInAppUpdateNotifications, 'showUpdateChangelogs': showUpdateChangelogs, 'inboxNotificationType': enableInboxNotifications, + 'pushNotificationServer': pushNotificationServer, 'showScoreCounters': showScoreCounters, 'appLanguage': appLanguage, 'compactView': compactView, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2b6b4b650..19bea23fb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -629,7 +629,7 @@ }, "failedToBlock": "Failed to block: {errorMessage}", "@failedToBlock": {}, - "failedToDisablePushNotifications": "Failed to disable existing push notifications. Please try again.", + "failedToDisablePushNotifications": "Failed to disable existing push notifications. Will try again when app restarts.", "@failedToDisablePushNotifications": { "description": "Error message when failed to disable push notifications." }, @@ -1285,6 +1285,10 @@ "@pushNotificationDescription": { "description": "Description of push notification setting" }, + "pushNotificationServer": "Push Notification Server", + "@pushNotificationServer": {}, + "pushNotificationServerDescription": "Configure the notification server to use for sending push notifications.", + "@pushNotificationServerDescription": {}, "reachedTheBottom": "Hmmm. It seems like you've reached the bottom.", "@reachedTheBottom": {}, "readAll": "Read All", diff --git a/lib/main.dart b/lib/main.dart index 1f2763d77..c4cda6805 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -37,6 +37,7 @@ import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/core/theme/bloc/theme_bloc.dart'; import 'package:thunder/instance/bloc/instance_bloc.dart'; import 'package:thunder/notification/notifications.dart'; +import 'package:thunder/notification/shared/notification_server.dart'; import 'package:thunder/routes.dart'; import 'package:thunder/thunder/cubits/notifications_cubit/notifications_cubit.dart'; import 'package:thunder/thunder/thunder.dart'; @@ -114,10 +115,19 @@ class _ThunderAppState extends State { WidgetsBinding.instance.addPostFrameCallback((_) async { SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; + String? inboxNotificationType = prefs.getString(LocalSettings.inboxNotificationType.name); - if (NotificationType.values.byName(prefs.getString(LocalSettings.inboxNotificationType.name) ?? NotificationType.none.name) != NotificationType.none) { + if (NotificationType.values.byName(inboxNotificationType ?? NotificationType.none.name) != NotificationType.none) { // Initialize notification logic initPushNotificationLogic(controller: notificationsStreamController); + } else if (inboxNotificationType != null && inboxNotificationType == NotificationType.none.name) { + // Attempt to remove tokens from notification server. When inboxNotificationType == NotificationType.none.name, that means at some point in time + // removing the tokens was unsuccessful. When there is a successful removal, the inboxNotificationType will be set to null. + bool success = await deleteAccountFromNotificationServer(); + if (success) { + prefs.remove(LocalSettings.inboxNotificationType.name); + debugPrint('Removed tokens from notification server'); + } } }); } diff --git a/lib/notification/shared/notification_server.dart b/lib/notification/shared/notification_server.dart index c42264467..dcc04cb47 100644 --- a/lib/notification/shared/notification_server.dart +++ b/lib/notification/shared/notification_server.dart @@ -9,12 +9,11 @@ import 'package:http/http.dart' as http; // Project imports: import 'package:thunder/account/models/account.dart'; +import 'package:thunder/core/enums/local_settings.dart'; +import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/notification/enums/notification_type.dart'; import 'package:thunder/utils/constants.dart'; -// String notificationServerUrl = '$THUNDER_SERVER_URL/notifications'; -String notificationServerUrl = 'http://192.168.50.195:5100/notifications'; - Future sendAuthTokenToNotificationServer({ required NotificationType type, required String token, @@ -22,9 +21,14 @@ Future sendAuthTokenToNotificationServer({ required String instance, }) async { try { + final prefs = (await UserPreferences.instance).sharedPreferences; + + String pushNotificationServer = prefs.getString(LocalSettings.pushNotificationServer.name) ?? THUNDER_SERVER_URL; + pushNotificationServer = 'http://192.168.50.195:5100/notifications'; + // Send POST request to notification server http.Response response = await http.post( - Uri.parse(notificationServerUrl), + Uri.parse(pushNotificationServer), headers: { 'Content-Type': 'application/json; charset=UTF-8', }, @@ -37,7 +41,7 @@ Future sendAuthTokenToNotificationServer({ ); // Check if the request was successful - if (response.statusCode == 200) return true; + if (response.statusCode == 201) return true; return false; } catch (e) { debugPrint(e.toString()); @@ -47,12 +51,17 @@ Future sendAuthTokenToNotificationServer({ Future deleteAccountFromNotificationServer() async { try { + final prefs = (await UserPreferences.instance).sharedPreferences; + + String pushNotificationServer = prefs.getString(LocalSettings.pushNotificationServer.name) ?? THUNDER_SERVER_URL; + pushNotificationServer = 'http://192.168.50.195:5100/notifications'; + List accounts = await Account.accounts(); List jwts = accounts.map((Account account) => account.jwt!).toList(); // Send POST request to notification server http.Response response = await http.delete( - Uri.parse(notificationServerUrl), + Uri.parse(pushNotificationServer), headers: { 'Content-Type': 'application/json; charset=UTF-8', }, diff --git a/lib/settings/pages/debug_settings_page.dart b/lib/settings/pages/debug_settings_page.dart index 5f02c1cb7..800aaca77 100644 --- a/lib/settings/pages/debug_settings_page.dart +++ b/lib/settings/pages/debug_settings_page.dart @@ -10,6 +10,7 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:thunder/core/enums/local_settings.dart'; +import 'package:thunder/notification/shared/notification_server.dart'; import 'package:thunder/shared/dialogs.dart'; import 'package:thunder/shared/snackbar.dart'; diff --git a/lib/settings/pages/general_settings_page.dart b/lib/settings/pages/general_settings_page.dart index 62772f053..8726debae 100644 --- a/lib/settings/pages/general_settings_page.dart +++ b/lib/settings/pages/general_settings_page.dart @@ -12,6 +12,7 @@ import 'package:android_intent_plus/android_intent.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:thunder/account/models/account.dart'; import 'package:thunder/core/enums/browser_mode.dart'; import 'package:thunder/core/enums/image_caching_mode.dart'; @@ -93,6 +94,9 @@ class _GeneralSettingsPageState extends State with SingleTi /// When enabled, system-level notifications will be displayed for new inbox messages NotificationType inboxNotificationType = NotificationType.none; + /// The URL of the push notification server + String pushNotificationServer = ''; + /// When enabled, authors and community names will be tappable when in compact view bool tappableAuthorCommunity = false; @@ -122,6 +126,9 @@ class _GeneralSettingsPageState extends State with SingleTi /// List of authenticated accounts List accounts = []; + /// Controller for the navigation server URL + TextEditingController controller = TextEditingController(); + Future setPreferences(attribute, value) async { final prefs = (await UserPreferences.instance).sharedPreferences; @@ -205,6 +212,10 @@ class _GeneralSettingsPageState extends State with SingleTi await prefs.setString(LocalSettings.inboxNotificationType.name, (value as NotificationType).name); setState(() => inboxNotificationType = value); break; + case LocalSettings.pushNotificationServer: + await prefs.setString(LocalSettings.pushNotificationServer.name, value); + setState(() => pushNotificationServer = value); + break; case LocalSettings.imageCachingMode: await prefs.setString(LocalSettings.imageCachingMode.name, value); @@ -262,6 +273,8 @@ class _GeneralSettingsPageState extends State with SingleTi showInAppUpdateNotification = prefs.getBool(LocalSettings.showInAppUpdateNotification.name) ?? false; showUpdateChangelogs = prefs.getBool(LocalSettings.showUpdateChangelogs.name) ?? true; inboxNotificationType = NotificationType.values.byName(prefs.getString(LocalSettings.inboxNotificationType.name) ?? NotificationType.none.name); + pushNotificationServer = prefs.getString(LocalSettings.pushNotificationServer.name) ?? THUNDER_SERVER_URL; + controller.text = pushNotificationServer; accounts = allAccounts; }); @@ -723,23 +736,23 @@ class _GeneralSettingsPageState extends State with SingleTi if (Platform.isAndroid) { disableBackgroundFetch(); UnifiedPush.unregister(); - } else if (Platform.isIOS) { - // TODO: Disable APNs } + bool successfullyRemovedExistingTokens = false; + // Delete all server tokens related to all accounts if the option was previously unified push or apns if (inboxNotificationType == NotificationType.unifiedPush || inboxNotificationType == NotificationType.apn) { - bool success = await deleteAccountFromNotificationServer(); - if (!success) { - showSnackbar(l10n.failedToDisablePushNotifications); - return; - } + successfullyRemovedExistingTokens = await deleteAccountFromNotificationServer(); } - // If disabled, do nothing - if (notificationType.payload == NotificationType.none) { - setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); - return; + if (notificationType.payload == NotificationType.none && successfullyRemovedExistingTokens) { + // If we have successfully removed all tokens from the server, we'll remove the preference altogether + SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; + prefs.remove(LocalSettings.inboxNotificationType.name); + debugPrint('Removed tokens from notification server'); + } else if (notificationType.payload == NotificationType.none && !successfullyRemovedExistingTokens) { + showSnackbar(l10n.failedToDisablePushNotifications); + return setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); } // If using local notifications, show a warning @@ -819,6 +832,57 @@ class _GeneralSettingsPageState extends State with SingleTi ), ), ), + SliverToBoxAdapter( + child: SettingsListTile( + icon: Icons.electrical_services_rounded, + description: l10n.pushNotificationServer, + subtitle: pushNotificationServer, + widget: const SizedBox( + height: 42.0, + child: Icon(Icons.chevron_right_rounded), + ), + onTap: () async { + showThunderDialog( + context: context, + title: l10n.pushNotificationServer, + contentWidgetBuilder: (_) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.pushNotificationServerDescription, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.textTheme.bodySmall?.color?.withOpacity(0.8), + ), + ), + const SizedBox(height: 16.0), + TextField( + textInputAction: TextInputAction.done, + keyboardType: TextInputType.url, + autocorrect: false, + controller: controller, + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + labelText: l10n.url, + hintText: THUNDER_SERVER_URL, + ), + enableSuggestions: false, + ), + ], + ); + }, + secondaryButtonText: l10n.cancel, + onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), + primaryButtonText: l10n.confirm, + onPrimaryButtonPressed: (dialogContext, _) { + setPreferences(LocalSettings.pushNotificationServer, controller.text); + Navigator.of(dialogContext).pop(); + }, + ); + }, + ), + ), const SliverToBoxAdapter(child: SizedBox(height: 16.0)), SliverToBoxAdapter( child: Padding( diff --git a/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart b/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart index 84da977d2..9fc2fb4ca 100644 --- a/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart +++ b/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart @@ -4,7 +4,7 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import '../../../notification/shared/android_notification.dart'; +import 'package:thunder/notification/shared/android_notification.dart'; part 'notifications_state.dart'; From 34893f16e3e329b6375247044252b48d6790f8de Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Mon, 22 Apr 2024 09:27:51 -0700 Subject: [PATCH 14/18] cleaned up logic, added additional comments --- lib/l10n/app_en.arb | 14 +- lib/main.dart | 13 +- .../shared/android_notification.dart | 13 +- .../shared/notification_server.dart | 35 +- lib/notification/utils/apns.dart | 19 +- .../utils/local_notifications.dart | 1 - lib/notification/utils/unified_push.dart | 4 +- lib/settings/pages/general_settings_page.dart | 402 +++++++++--------- 8 files changed, 254 insertions(+), 247 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 19bea23fb..4151051d2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -629,7 +629,7 @@ }, "failedToBlock": "Failed to block: {errorMessage}", "@failedToBlock": {}, - "failedToDisablePushNotifications": "Failed to disable existing push notifications. Will try again when app restarts.", + "failedToDisablePushNotifications": "Failed to disable push notifications", "@failedToDisablePushNotifications": { "description": "Error message when failed to disable push notifications." }, @@ -1281,14 +1281,18 @@ "@pushNotification": { "description": "Setting for push notifications" }, - "pushNotificationDescription": "If enabled, Thunder will send your JWT token(s) to the server in order to poll for new notifications. \\n\\n **NOTE:** This will not take effect until the next time the app is launched.", + "pushNotificationDescription": "If enabled, Thunder will send your JWT token(s) to the server in order to poll for new notifications. \n\n **NOTE:** This will not take effect until the next time the app is launched.", "@pushNotificationDescription": { "description": "Description of push notification setting" }, "pushNotificationServer": "Push Notification Server", - "@pushNotificationServer": {}, - "pushNotificationServerDescription": "Configure the notification server to use for sending push notifications.", - "@pushNotificationServerDescription": {}, + "@pushNotificationServer": { + "description": "Setting for choosing push notification server" + }, + "pushNotificationServerDescription": "Configure the push notification server. The server must be properly configured to send push notifications to your device.\n\n **Only enter a server that you trust with your credentials.**", + "@pushNotificationServerDescription": { + "description": "Description of choosing push notification server setting" + }, "reachedTheBottom": "Hmmm. It seems like you've reached the bottom.", "@reachedTheBottom": {}, "readAll": "Read All", diff --git a/lib/main.dart b/lib/main.dart index c4cda6805..822e618f7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -117,13 +117,18 @@ class _ThunderAppState extends State { SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; String? inboxNotificationType = prefs.getString(LocalSettings.inboxNotificationType.name); - if (NotificationType.values.byName(inboxNotificationType ?? NotificationType.none.name) != NotificationType.none) { + // If notification type is null, then don't perform any logic + if (inboxNotificationType == null) return; + + if (NotificationType.values.byName(inboxNotificationType) != NotificationType.none) { // Initialize notification logic initPushNotificationLogic(controller: notificationsStreamController); - } else if (inboxNotificationType != null && inboxNotificationType == NotificationType.none.name) { - // Attempt to remove tokens from notification server. When inboxNotificationType == NotificationType.none.name, that means at some point in time - // removing the tokens was unsuccessful. When there is a successful removal, the inboxNotificationType will be set to null. + } else { + // Attempt to remove tokens from notification server. When inboxNotificationType == NotificationType.none, + // this indicates that removing token was unsuccessful previously. We will attempt to remove it again. + // When there is a successful removal, the inboxNotificationType will be set to null. bool success = await deleteAccountFromNotificationServer(); + if (success) { prefs.remove(LocalSettings.inboxNotificationType.name); debugPrint('Removed tokens from notification server'); diff --git a/lib/notification/shared/android_notification.dart b/lib/notification/shared/android_notification.dart index f73969843..7cec93e72 100644 --- a/lib/notification/shared/android_notification.dart +++ b/lib/notification/shared/android_notification.dart @@ -8,9 +8,10 @@ const String _inboxMessagesChannelName = 'Inbox Messages'; const String repliesGroupKey = 'replies'; /// Displays a new notification group on Android based on the accounts passed in. -void showNotificationGroups({ - List accounts = const [], -}) async { +/// +/// This displays an empty notification which will be used in conjunction with the [showAndroidNotification] +/// to help display a group of notifications on Android. +void showNotificationGroups({List accounts = const []}) async { final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); for (Account account in accounts) { @@ -42,9 +43,8 @@ void showNotificationGroups({ } } -/// Displays a single push notification on Android -/// -/// The notification will be grouped based on the account id. +/// Displays a single push notification on Android. When a notification is displayed, it will be grouped by the account id. +/// This allows us to group notifications for a single account on Android. void showAndroidNotification({ required int id, required BigTextStyleInformation bigTextStyleInformation, @@ -52,7 +52,6 @@ void showAndroidNotification({ String title = '', String content = '', String payload = '', - String summaryText = '', }) async { final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); diff --git a/lib/notification/shared/notification_server.dart b/lib/notification/shared/notification_server.dart index dcc04cb47..0c69d847f 100644 --- a/lib/notification/shared/notification_server.dart +++ b/lib/notification/shared/notification_server.dart @@ -1,41 +1,41 @@ -// Dart imports: +// Dart imports import 'dart:convert'; -// Flutter imports: +// Flutter imports import 'package:flutter/foundation.dart'; -// Package imports: +// Package imports import 'package:http/http.dart' as http; -// Project imports: +// Project imports import 'package:thunder/account/models/account.dart'; import 'package:thunder/core/enums/local_settings.dart'; import 'package:thunder/core/singletons/preferences.dart'; import 'package:thunder/notification/enums/notification_type.dart'; import 'package:thunder/utils/constants.dart'; +/// Sends a request to the push notification server, including the [NotificationType], [jwt], and [instance]. +/// +/// The [token] describes the endpoint to send the notification to. This is generally the UnifiedPush endpoint, or device token for APNs. +/// The [instance] and [jwt] are required in order for the push server to act on behalf of the user to poll for notifications. Future sendAuthTokenToNotificationServer({ required NotificationType type, required String token, - required List jwts, + required String jwt, required String instance, }) async { try { final prefs = (await UserPreferences.instance).sharedPreferences; - String pushNotificationServer = prefs.getString(LocalSettings.pushNotificationServer.name) ?? THUNDER_SERVER_URL; - pushNotificationServer = 'http://192.168.50.195:5100/notifications'; // Send POST request to notification server http.Response response = await http.post( Uri.parse(pushNotificationServer), - headers: { - 'Content-Type': 'application/json; charset=UTF-8', - }, + headers: {'Content-Type': 'application/json; charset=UTF-8'}, body: jsonEncode({ 'type': type.name, 'token': token, - 'jwts': jwts, + 'jwt': jwt, 'instance': instance, }), ); @@ -49,12 +49,13 @@ Future sendAuthTokenToNotificationServer({ } } +/// Sends a request to the push notification server to remove any account tokens from the server. This will remove push notifications for all accounts active on the app. +/// +/// This is generally called when the user changes push notification types, or disables all push notifications. Future deleteAccountFromNotificationServer() async { try { final prefs = (await UserPreferences.instance).sharedPreferences; - String pushNotificationServer = prefs.getString(LocalSettings.pushNotificationServer.name) ?? THUNDER_SERVER_URL; - pushNotificationServer = 'http://192.168.50.195:5100/notifications'; List accounts = await Account.accounts(); List jwts = accounts.map((Account account) => account.jwt!).toList(); @@ -62,12 +63,8 @@ Future deleteAccountFromNotificationServer() async { // Send POST request to notification server http.Response response = await http.delete( Uri.parse(pushNotificationServer), - headers: { - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: jsonEncode({ - 'jwts': jwts, - }), + headers: {'Content-Type': 'application/json; charset=UTF-8'}, + body: jsonEncode({'jwts': jwts}), ); // Check if the request was successful diff --git a/lib/notification/utils/apns.dart b/lib/notification/utils/apns.dart index a8b19f716..0867176e2 100644 --- a/lib/notification/utils/apns.dart +++ b/lib/notification/utils/apns.dart @@ -20,13 +20,12 @@ import 'package:thunder/notification/shared/notification_server.dart'; void initAPNs({required StreamController controller}) async { const String repliesGroupKey = 'replies'; - // Fetch device token for APNs - // We need to send this device token along with the jwt so that the server can poll for new notifications and send them to this device. + // Fetch device token for APNs. We need to send this device token along with the jwt so that the server can poll for new notifications and send them to this device. String? token = await Push.instance.token; debugPrint("Device token: $token"); if (token == null) { - debugPrint("No device token, skipping APNs initialization"); + debugPrint("No device token found, skipping APNs initialization"); return; } @@ -35,7 +34,7 @@ void initAPNs({required StreamController controller}) asyn // TODO: Select accounts to enable push notifications for (Account account in accounts) { - bool success = await sendAuthTokenToNotificationServer(type: NotificationType.apn, token: token, jwts: [account.jwt!], instance: account.instance!); + bool success = await sendAuthTokenToNotificationServer(type: NotificationType.apn, token: token, jwt: account.jwt!, instance: account.instance!); if (!success) debugPrint("Failed to send device token to server for account ${account.id}. Skipping."); } @@ -49,7 +48,7 @@ void initAPNs({required StreamController controller}) asyn // TODO: Select accounts to enable push notifications for (Account account in accounts) { - bool success = await sendAuthTokenToNotificationServer(type: NotificationType.apn, token: token, jwts: [account.jwt!], instance: account.instance!); + bool success = await sendAuthTokenToNotificationServer(type: NotificationType.apn, token: token, jwt: account.jwt!, instance: account.instance!); if (!success) debugPrint("Failed to send device token to server for account ${account.id}. Skipping."); } }); @@ -59,14 +58,20 @@ void initAPNs({required StreamController controller}) asyn if (data == null) return; if (data.containsKey(repliesGroupKey)) { - controller.add(NotificationResponse(payload: data[repliesGroupKey] as String, notificationResponseType: NotificationResponseType.selectedNotification)); + controller.add(NotificationResponse( + payload: data[repliesGroupKey] as String, + notificationResponseType: NotificationResponseType.selectedNotification, + )); } }); /// Handle notification taps. This triggers when the user taps on a notification when the app is on the foreground or background. Push.instance.onNotificationTap.listen((data) { if (data.containsKey(repliesGroupKey)) { - controller.add(NotificationResponse(payload: data[repliesGroupKey] as String, notificationResponseType: NotificationResponseType.selectedNotification)); + controller.add(NotificationResponse( + payload: data[repliesGroupKey] as String, + notificationResponseType: NotificationResponseType.selectedNotification, + )); } }); } diff --git a/lib/notification/utils/local_notifications.dart b/lib/notification/utils/local_notifications.dart index 62da23965..8346e80be 100644 --- a/lib/notification/utils/local_notifications.dart +++ b/lib/notification/utils/local_notifications.dart @@ -105,7 +105,6 @@ Future pollRepliesAndShowNotifications() async { title: generateUserFullName(null, commentReplyView.creator.name, fetchInstanceNameFromUrl(commentReplyView.creator.actorId), userSeparator: userSeparator), content: plaintextComment, payload: '$repliesGroupKey-${commentReplyView.comment.id}', - summaryText: generateUserFullName(null, commentReplyView.recipient.name, fetchInstanceNameFromUrl(commentReplyView.recipient.actorId), userSeparator: userSeparator), ); } } diff --git a/lib/notification/utils/unified_push.dart b/lib/notification/utils/unified_push.dart index 108139053..860973099 100644 --- a/lib/notification/utils/unified_push.dart +++ b/lib/notification/utils/unified_push.dart @@ -44,7 +44,7 @@ void initUnifiedPushNotifications({required StreamController with SingleTi GlobalKey settingToHighlightKey = GlobalKey(); LocalSettings? settingToHighlight; - /// List of authenticated accounts + /// List of authenticated accounts. Used to determine if push notifications are enabled List accounts = []; - /// Controller for the navigation server URL + /// Controller for the push notification server URL TextEditingController controller = TextEditingController(); Future setPreferences(attribute, value) async { @@ -235,8 +236,8 @@ class _GeneralSettingsPageState extends State with SingleTi void _initPreferences() async { final prefs = (await UserPreferences.instance).sharedPreferences; - // Get all accounts - List allAccounts = await Account.accounts(); + // Get all currently active accounts + List accountList = await Account.accounts(); setState(() { // Default Sorts and Listing @@ -276,7 +277,7 @@ class _GeneralSettingsPageState extends State with SingleTi pushNotificationServer = prefs.getString(LocalSettings.pushNotificationServer.name) ?? THUNDER_SERVER_URL; controller.text = pushNotificationServer; - accounts = allAccounts; + accounts = accountList; }); } @@ -679,210 +680,209 @@ class _GeneralSettingsPageState extends State with SingleTi highlightKey: settingToHighlight == LocalSettings.showUpdateChangelogs ? settingToHighlightKey : null, ), ), - SliverToBoxAdapter( - child: ListOption( - description: l10n.enableInboxNotifications, - subtitle: accounts.isEmpty ? l10n.disabled : inboxNotificationType.toString(), - value: const ListPickerItem(payload: -1), - disabled: accounts.isEmpty, - icon: inboxNotificationType == NotificationType.none ? Icons.notifications_off_rounded : Icons.notifications_on_rounded, - highlightKey: settingToHighlight == LocalSettings.inboxNotificationType ? settingToHighlightKey : null, - customListPicker: StatefulBuilder( - builder: (context, setState) { - return BottomSheetListPicker( - title: l10n.pushNotification, - heading: Align( - alignment: Alignment.centerLeft, - child: CommonMarkdownBody(body: l10n.pushNotificationDescription), - ), - previouslySelected: inboxNotificationType, - items: Platform.isAndroid - ? [ - ListPickerItem( - icon: Icons.notifications_off_rounded, - label: l10n.none, - payload: NotificationType.none, - ), - ListPickerItem( - icon: Icons.notifications_rounded, - label: l10n.useLocalNotifications, - subtitle: l10n.useLocalNotificationsDescription, - payload: NotificationType.local, - ), - ListPickerItem( - icon: Icons.notifications_active_rounded, - label: l10n.useUnifiedPushNotifications, - subtitle: l10n.useUnifiedPushNotificationsDescription, - payload: NotificationType.unifiedPush, - ), - ] - : [ - ListPickerItem( - icon: Icons.notifications_off_rounded, - label: l10n.disablePushNotifications, - payload: NotificationType.none, - ), - ListPickerItem( - icon: Icons.notifications_active_rounded, - label: l10n.useApplePushNotifications, - subtitle: l10n.useApplePushNotificationsDescription, - payload: NotificationType.apn, - ), - ], - onSelect: (ListPickerItem notificationType) async { - if (notificationType.payload == inboxNotificationType) return; - - // Disable all notifications since the option has changed. - if (Platform.isAndroid) { - disableBackgroundFetch(); - UnifiedPush.unregister(); - } - - bool successfullyRemovedExistingTokens = false; - - // Delete all server tokens related to all accounts if the option was previously unified push or apns - if (inboxNotificationType == NotificationType.unifiedPush || inboxNotificationType == NotificationType.apn) { - successfullyRemovedExistingTokens = await deleteAccountFromNotificationServer(); - } - - if (notificationType.payload == NotificationType.none && successfullyRemovedExistingTokens) { - // If we have successfully removed all tokens from the server, we'll remove the preference altogether - SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - prefs.remove(LocalSettings.inboxNotificationType.name); - debugPrint('Removed tokens from notification server'); - } else if (notificationType.payload == NotificationType.none && !successfullyRemovedExistingTokens) { - showSnackbar(l10n.failedToDisablePushNotifications); - return setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); - } - - // If using local notifications, show a warning - if (notificationType.payload == NotificationType.local) { - bool res = false; - - await showThunderDialog( - context: context, - title: l10n.warning, - contentWidgetBuilder: (_) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - CommonMarkdownBody(body: l10n.notificationsWarningDialog), - const SizedBox(height: 5), - Align( - alignment: Alignment.centerLeft, - child: GestureDetector( - onTap: () => handleLink(context, url: 'https://dontkillmyapp.com/'), - child: Text( - 'https://dontkillmyapp.com/', - style: theme.textTheme.bodyMedium?.copyWith(color: Colors.blue), - ), - ), + if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) ...[ + SliverToBoxAdapter( + child: ListOption( + description: l10n.enableInboxNotifications, + subtitle: accounts.isEmpty ? l10n.disabled : inboxNotificationType.toString(), + value: const ListPickerItem(payload: -1), + disabled: accounts.isEmpty, + icon: inboxNotificationType == NotificationType.none ? Icons.notifications_off_rounded : Icons.notifications_on_rounded, + highlightKey: settingToHighlight == LocalSettings.inboxNotificationType ? settingToHighlightKey : null, + customListPicker: StatefulBuilder( + builder: (context, setState) { + return BottomSheetListPicker( + title: l10n.pushNotification, + heading: Align( + alignment: Alignment.centerLeft, + child: CommonMarkdownBody(body: l10n.pushNotificationDescription), + ), + previouslySelected: inboxNotificationType, + items: Platform.isAndroid + ? [ + ListPickerItem( + icon: Icons.notifications_off_rounded, + label: l10n.none, + payload: NotificationType.none, + ), + ListPickerItem( + icon: Icons.notifications_rounded, + label: l10n.useLocalNotifications, + subtitle: l10n.useLocalNotificationsDescription, + payload: NotificationType.local, + ), + ListPickerItem( + icon: Icons.notifications_active_rounded, + label: l10n.useUnifiedPushNotifications, + subtitle: l10n.useUnifiedPushNotificationsDescription, + payload: NotificationType.unifiedPush, + ), + ] + : [ + ListPickerItem( + icon: Icons.notifications_off_rounded, + label: l10n.disablePushNotifications, + payload: NotificationType.none, + ), + ListPickerItem( + icon: Icons.notifications_active_rounded, + label: l10n.useApplePushNotifications, + subtitle: l10n.useApplePushNotificationsDescription, + payload: NotificationType.apn, ), ], + onSelect: (ListPickerItem notificationType) async { + if (notificationType.payload == inboxNotificationType) return; + + // Disable all notifications since the option has changed. + if (Platform.isAndroid) { + disableBackgroundFetch(); + UnifiedPush.unregister(); + } + + bool successfullyRemovedExistingTokens = false; + + // Delete all server tokens related to all accounts if the option was previously unified push or apns + if (inboxNotificationType == NotificationType.unifiedPush || inboxNotificationType == NotificationType.apn) { + successfullyRemovedExistingTokens = await deleteAccountFromNotificationServer(); + } + + if (notificationType.payload == NotificationType.none && successfullyRemovedExistingTokens) { + // If we have successfully removed all tokens from the server, we'll remove the preference altogether + SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; + prefs.remove(LocalSettings.inboxNotificationType.name); + debugPrint('Removed tokens from notification server'); + } else if (notificationType.payload == NotificationType.none && !successfullyRemovedExistingTokens) { + // If we failed to remove all tokens from the server, we'll set the preference to NotificationType.none + // The next time the app is opened, it will attempt to remove tokens from the server + showSnackbar(l10n.failedToDisablePushNotifications); + return setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); + } + + // If using local notifications, show a warning + if (notificationType.payload == NotificationType.local) { + bool res = false; + + await showThunderDialog( + context: context, + title: l10n.warning, + contentWidgetBuilder: (_) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + CommonMarkdownBody(body: l10n.notificationsWarningDialog), + const SizedBox(height: 5), + Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + onTap: () => handleLink(context, url: 'https://dontkillmyapp.com/'), + child: Text( + 'https://dontkillmyapp.com/', + style: theme.textTheme.bodyMedium?.copyWith(color: Colors.blue), + ), + ), + ), + ], + ), + primaryButtonText: l10n.understandEnable, + onPrimaryButtonPressed: (dialogContext, _) { + res = true; + dialogContext.pop(); + }, + secondaryButtonText: l10n.cancel, + onSecondaryButtonPressed: (dialogContext) => dialogContext.pop(), + ); + + if (!res) return; + } + + // Check notifications permissions and enable them if needed + switch (notificationType.payload) { + case NotificationType.local: + case NotificationType.unifiedPush: + // We're on Android. Request notifications permissions if needed. This is a no-op if on SDK version < 33 + AndroidFlutterLocalNotificationsPlugin? androidFlutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); + + bool? areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.areNotificationsEnabled(); + + if (areAndroidNotificationsAllowed != true) { + areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.requestNotificationsPermission(); + if (areAndroidNotificationsAllowed != true) return showSnackbar(l10n.permissionDenied); + } + + // Permissions have been granted, so we can enable notifications + setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); + break; + case NotificationType.apn: + // We're on iOS. Request notifications permissions if needed. + IOSFlutterLocalNotificationsPlugin? iosFlutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); + + NotificationsEnabledOptions? notificationsEnabledOptions = await iosFlutterLocalNotificationsPlugin?.checkPermissions(); + + if (notificationsEnabledOptions?.isEnabled != true) { + bool? isEnabled = await iosFlutterLocalNotificationsPlugin?.requestPermissions(alert: true, badge: true, sound: true); + if (isEnabled != true) return showSnackbar(l10n.permissionDenied); + } + + setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); + break; + default: + break; + } + }, + ); + }, + ), + ), + ), + SliverToBoxAdapter( + child: SettingsListTile( + icon: Icons.electrical_services_rounded, + description: l10n.pushNotificationServer, + subtitle: pushNotificationServer, + widget: const SizedBox( + height: 42.0, + child: Icon(Icons.chevron_right_rounded), + ), + onTap: () async { + showThunderDialog( + context: context, + title: l10n.pushNotificationServer, + contentWidgetBuilder: (_) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + CommonMarkdownBody(body: l10n.pushNotificationServerDescription), + const SizedBox(height: 32.0), + TextField( + textInputAction: TextInputAction.done, + keyboardType: TextInputType.url, + autocorrect: false, + controller: controller, + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + labelText: l10n.url, + hintText: THUNDER_SERVER_URL, + ), + enableSuggestions: false, ), - primaryButtonText: l10n.understandEnable, - onPrimaryButtonPressed: (dialogContext, _) { - res = true; - dialogContext.pop(); - }, - secondaryButtonText: l10n.cancel, - onSecondaryButtonPressed: (dialogContext) => dialogContext.pop(), - ); - - if (!res) return; - } - - // Check notifications permissions and enable them if needed - switch (notificationType.payload) { - case NotificationType.local: - case NotificationType.unifiedPush: - // We're on Android. Request notifications permissions if needed. This is a no-op if on SDK version < 33 - AndroidFlutterLocalNotificationsPlugin? androidFlutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); - - bool? areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.areNotificationsEnabled(); - - if (areAndroidNotificationsAllowed != true) { - areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.requestNotificationsPermission(); - if (areAndroidNotificationsAllowed != true) return showSnackbar(l10n.permissionDenied); - } - - // Permissions have been granted, so we can enable notifications - setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); - break; - case NotificationType.apn: - // We're on iOS. Request notifications permissions if needed. - IOSFlutterLocalNotificationsPlugin? iosFlutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); - - NotificationsEnabledOptions? notificationsEnabledOptions = await iosFlutterLocalNotificationsPlugin?.checkPermissions(); - - if (notificationsEnabledOptions?.isEnabled != true) { - bool? isEnabled = await iosFlutterLocalNotificationsPlugin?.requestPermissions(alert: true, badge: true, sound: true); - if (isEnabled != true) return showSnackbar(l10n.permissionDenied); - } - - setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); - break; - default: - break; - } + ], + ); + }, + secondaryButtonText: l10n.cancel, + onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), + primaryButtonText: l10n.confirm, + onPrimaryButtonPressed: (dialogContext, _) { + setPreferences(LocalSettings.pushNotificationServer, controller.text); + Navigator.of(dialogContext).pop(); }, ); }, ), ), - ), - SliverToBoxAdapter( - child: SettingsListTile( - icon: Icons.electrical_services_rounded, - description: l10n.pushNotificationServer, - subtitle: pushNotificationServer, - widget: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), - onTap: () async { - showThunderDialog( - context: context, - title: l10n.pushNotificationServer, - contentWidgetBuilder: (_) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - l10n.pushNotificationServerDescription, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.textTheme.bodySmall?.color?.withOpacity(0.8), - ), - ), - const SizedBox(height: 16.0), - TextField( - textInputAction: TextInputAction.done, - keyboardType: TextInputType.url, - autocorrect: false, - controller: controller, - decoration: InputDecoration( - isDense: true, - border: const OutlineInputBorder(), - labelText: l10n.url, - hintText: THUNDER_SERVER_URL, - ), - enableSuggestions: false, - ), - ], - ); - }, - secondaryButtonText: l10n.cancel, - onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), - primaryButtonText: l10n.confirm, - onPrimaryButtonPressed: (dialogContext, _) { - setPreferences(LocalSettings.pushNotificationServer, controller.text); - Navigator.of(dialogContext).pop(); - }, - ); - }, - ), - ), + ], const SliverToBoxAdapter(child: SizedBox(height: 16.0)), SliverToBoxAdapter( child: Padding( From 65dee87952eae1a9cb8c8a707ebb899b735d5a4a Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Tue, 23 Apr 2024 11:36:44 -0700 Subject: [PATCH 15/18] add softwrap option to picker item --- lib/settings/pages/general_settings_page.dart | 5 +++++ lib/shared/picker_item.dart | 4 ++++ lib/utils/bottom_sheet_list_picker.dart | 5 +++++ 3 files changed, 14 insertions(+) diff --git a/lib/settings/pages/general_settings_page.dart b/lib/settings/pages/general_settings_page.dart index 857837f22..05445c536 100644 --- a/lib/settings/pages/general_settings_page.dart +++ b/lib/settings/pages/general_settings_page.dart @@ -704,18 +704,21 @@ class _GeneralSettingsPageState extends State with SingleTi icon: Icons.notifications_off_rounded, label: l10n.none, payload: NotificationType.none, + softWrap: true, ), ListPickerItem( icon: Icons.notifications_rounded, label: l10n.useLocalNotifications, subtitle: l10n.useLocalNotificationsDescription, payload: NotificationType.local, + softWrap: true, ), ListPickerItem( icon: Icons.notifications_active_rounded, label: l10n.useUnifiedPushNotifications, subtitle: l10n.useUnifiedPushNotificationsDescription, payload: NotificationType.unifiedPush, + softWrap: true, ), ] : [ @@ -723,12 +726,14 @@ class _GeneralSettingsPageState extends State with SingleTi icon: Icons.notifications_off_rounded, label: l10n.disablePushNotifications, payload: NotificationType.none, + softWrap: true, ), ListPickerItem( icon: Icons.notifications_active_rounded, label: l10n.useApplePushNotifications, subtitle: l10n.useApplePushNotificationsDescription, payload: NotificationType.apn, + softWrap: true, ), ], onSelect: (ListPickerItem notificationType) async { diff --git a/lib/shared/picker_item.dart b/lib/shared/picker_item.dart index d652f1d5d..75ca39599 100644 --- a/lib/shared/picker_item.dart +++ b/lib/shared/picker_item.dart @@ -10,6 +10,7 @@ class PickerItem extends StatelessWidget { final void Function()? onSelected; final bool? isSelected; final TextTheme? textTheme; + final bool softWrap; const PickerItem({ super.key, @@ -22,6 +23,7 @@ class PickerItem extends StatelessWidget { this.trailingIcon, this.leading, this.textTheme, + this.softWrap = false, }); @override @@ -50,6 +52,8 @@ class PickerItem extends StatelessWidget { style: (textTheme?.bodyMedium ?? theme.textTheme.bodyMedium)?.copyWith( color: (textTheme?.bodyMedium ?? theme.textTheme.bodyMedium)?.color?.withOpacity(0.5), ), + softWrap: softWrap, + overflow: TextOverflow.fade, ) : null, leading: icon != null ? Icon(icon) : this.leading, diff --git a/lib/utils/bottom_sheet_list_picker.dart b/lib/utils/bottom_sheet_list_picker.dart index 736f4bf4e..f6d0067c3 100644 --- a/lib/utils/bottom_sheet_list_picker.dart +++ b/lib/utils/bottom_sheet_list_picker.dart @@ -140,6 +140,7 @@ class _BottomSheetListPickerState extends State> { false => Icons.check_box_outline_blank_rounded, null => null, }, + softWrap: item.softWrap, ); }, ).toList(), @@ -205,6 +206,9 @@ class ListPickerItem { /// Whether the item is selected final bool Function()? isChecked; + /// Whether the subtitle should softwrap + final bool softWrap; + const ListPickerItem({ this.icon, this.colors, @@ -216,5 +220,6 @@ class ListPickerItem { this.customWidget, required this.payload, this.isChecked, + this.softWrap = false, }); } From 6f8f622e947a4928e1e90af2b25677ee6acde1ed Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Tue, 23 Apr 2024 12:49:58 -0700 Subject: [PATCH 16/18] disable push notification server option when not using apns/unified push, moved notification setting into its own file for oreganization --- lib/l10n/app_en.arb | 4 + .../utils/notification_settings.dart | 138 ++++++++++++ lib/settings/pages/general_settings_page.dart | 208 +++++------------- 3 files changed, 200 insertions(+), 150 deletions(-) create mode 100644 lib/notification/utils/notification_settings.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4151051d2..a05803518 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -637,6 +637,10 @@ "@failedToLoadBlocks": {}, "failedToUnblock": "Could not unblock: {errorMessage}", "@failedToUnblock": {}, + "failedToUpdateNotificationSettings": "Failed to update notification settings", + "@failedToUpdateNotificationSettings": { + "description": "Error message when failed to update notification settings." + }, "favorites": "Favorites", "@favorites": { "description": "The favorited communities on the drawer" diff --git a/lib/notification/utils/notification_settings.dart b/lib/notification/utils/notification_settings.dart new file mode 100644 index 000000000..631c02153 --- /dev/null +++ b/lib/notification/utils/notification_settings.dart @@ -0,0 +1,138 @@ +// Flutter imports +import 'package:flutter/material.dart'; + +// Package imports +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:go_router/go_router.dart'; +import 'package:unifiedpush/unifiedpush.dart'; + +// Project imports +import 'package:thunder/core/enums/local_settings.dart'; +import 'package:thunder/core/singletons/preferences.dart'; +import 'package:thunder/notification/enums/notification_type.dart'; +import 'package:thunder/notification/shared/notification_server.dart'; +import 'package:thunder/notification/utils/local_notifications.dart'; +import 'package:thunder/shared/common_markdown_body.dart'; +import 'package:thunder/shared/dialogs.dart'; +import 'package:thunder/shared/snackbar.dart'; + +/// This function is used to update the notification settings. It is called when the user changes the notification settings. +/// +/// When the notification settings are successfully updated, it will return true. If it fails, it will return false. +Future updateNotificationSettings( + context, { + required NotificationType currentNotificationType, + required NotificationType updatedNotificationType, + Function? onUpdate, +}) async { + final l10n = AppLocalizations.of(context)!; + final prefs = (await UserPreferences.instance).sharedPreferences; + + // Disable background fetch and unregister unified push. This is only applied to Android. For iOS, simply deleting the token is enough. + // The user should be aware that restarting the app is required to update their push notification settings + switch (currentNotificationType) { + case NotificationType.local: + disableBackgroundFetch(); + break; + case NotificationType.unifiedPush: + UnifiedPush.unregister(); + break; + case NotificationType.apn: + case NotificationType.none: + break; + } + + // Perform any additional actions required if the notification type switches + if (currentNotificationType == NotificationType.local && updatedNotificationType == NotificationType.none) { + // If we are deactivating turning off push notifications, we'll remove the preference + prefs.remove(LocalSettings.inboxNotificationType.name); + return true; + } + + if (currentNotificationType == NotificationType.unifiedPush || currentNotificationType == NotificationType.apn) { + // If the current notification type is unified push or apns, we'll delete all tokens from the server first + bool success = await deleteAccountFromNotificationServer(); + + if (updatedNotificationType == NotificationType.none && success) { + // If we have successfully removed all tokens from the server, we'll remove the preference altogether + prefs.remove(LocalSettings.inboxNotificationType.name); + return true; + } else if (updatedNotificationType == NotificationType.none && !success) { + // If we failed to remove all tokens from the server, we'll set the preference to NotificationType.none + // The next time the app is opened, it will attempt to remove tokens from the server + showSnackbar(l10n.failedToDisablePushNotifications); + onUpdate?.call(updatedNotificationType); + return true; + } + } + + // If the new notification type is local, show a warning first + if (updatedNotificationType == NotificationType.local) { + bool acceptedWarning = false; + + await showThunderDialog( + context: context, + title: l10n.warning, + contentWidgetBuilder: (_) => Wrap( + runSpacing: 8.0, + children: [ + CommonMarkdownBody(body: l10n.notificationsWarningDialog), + const CommonMarkdownBody(body: 'https://dontkillmyapp.com/'), + ], + ), + primaryButtonText: l10n.understandEnable, + onPrimaryButtonPressed: (dialogContext, _) { + acceptedWarning = true; + dialogContext.pop(); + }, + secondaryButtonText: l10n.cancel, + onSecondaryButtonPressed: (dialogContext) => dialogContext.pop(), + ); + + if (!acceptedWarning) return true; + } + + // Finally, enable the new notification type + switch (updatedNotificationType) { + case NotificationType.local: + case NotificationType.unifiedPush: + // We're on Android. Request notifications permissions if needed. This is a no-op if on SDK version < 33 + AndroidFlutterLocalNotificationsPlugin? androidFlutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); + + bool? areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.areNotificationsEnabled(); + + if (areAndroidNotificationsAllowed != true) { + areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.requestNotificationsPermission(); + if (areAndroidNotificationsAllowed != true) { + showSnackbar(l10n.permissionDenied); + return Future.delayed(const Duration(seconds: 2)).then((_) => false); + } + } + + // Permissions have been granted, so we can enable notifications + onUpdate?.call(updatedNotificationType); + return true; + case NotificationType.apn: + // We're on iOS. Request notifications permissions if needed. + IOSFlutterLocalNotificationsPlugin? iosFlutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); + + NotificationsEnabledOptions? notificationsEnabledOptions = await iosFlutterLocalNotificationsPlugin?.checkPermissions(); + + if (notificationsEnabledOptions?.isEnabled != true) { + bool? areIOSNotificationsAllowed = await iosFlutterLocalNotificationsPlugin?.requestPermissions(alert: true, badge: true, sound: true); + if (areIOSNotificationsAllowed != true) { + showSnackbar(l10n.permissionDenied); + return Future.delayed(const Duration(seconds: 2)).then((_) => false); + } + } + + onUpdate?.call(updatedNotificationType); + return true; + default: + break; + } + + return false; +} diff --git a/lib/settings/pages/general_settings_page.dart b/lib/settings/pages/general_settings_page.dart index 05445c536..2240f46b2 100644 --- a/lib/settings/pages/general_settings_page.dart +++ b/lib/settings/pages/general_settings_page.dart @@ -3,17 +3,12 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:go_router/go_router.dart'; - import 'package:lemmy_api_client/v3.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:android_intent_plus/android_intent.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:thunder/account/models/account.dart'; import 'package:thunder/core/enums/browser_mode.dart'; import 'package:thunder/core/enums/image_caching_mode.dart'; @@ -22,7 +17,7 @@ import 'package:thunder/core/enums/local_settings.dart'; import 'package:thunder/notification/enums/notification_type.dart'; import 'package:thunder/core/singletons/lemmy_client.dart'; import 'package:thunder/core/singletons/preferences.dart'; -import 'package:thunder/notification/utils/local_notifications.dart'; +import 'package:thunder/notification/utils/notification_settings.dart'; import 'package:thunder/settings/widgets/list_option.dart'; import 'package:thunder/settings/widgets/settings_list_tile.dart'; import 'package:thunder/settings/widgets/toggle_option.dart'; @@ -35,9 +30,6 @@ import 'package:thunder/thunder/bloc/thunder_bloc.dart'; import 'package:thunder/utils/bottom_sheet_list_picker.dart'; import 'package:thunder/utils/constants.dart'; import 'package:thunder/utils/language/language.dart'; -import 'package:thunder/utils/links.dart'; -import 'package:thunder/notification/shared/notification_server.dart'; -import 'package:unifiedpush/unifiedpush.dart'; import 'package:version/version.dart'; class GeneralSettingsPage extends StatefulWidget { @@ -684,7 +676,7 @@ class _GeneralSettingsPageState extends State with SingleTi SliverToBoxAdapter( child: ListOption( description: l10n.enableInboxNotifications, - subtitle: accounts.isEmpty ? l10n.disabled : inboxNotificationType.toString(), + subtitle: accounts.isEmpty ? l10n.loginToPerformAction : inboxNotificationType.toString(), value: const ListPickerItem(payload: -1), disabled: accounts.isEmpty, icon: inboxNotificationType == NotificationType.none ? Icons.notifications_off_rounded : Icons.notifications_on_rounded, @@ -739,154 +731,70 @@ class _GeneralSettingsPageState extends State with SingleTi onSelect: (ListPickerItem notificationType) async { if (notificationType.payload == inboxNotificationType) return; - // Disable all notifications since the option has changed. - if (Platform.isAndroid) { - disableBackgroundFetch(); - UnifiedPush.unregister(); - } - - bool successfullyRemovedExistingTokens = false; - - // Delete all server tokens related to all accounts if the option was previously unified push or apns - if (inboxNotificationType == NotificationType.unifiedPush || inboxNotificationType == NotificationType.apn) { - successfullyRemovedExistingTokens = await deleteAccountFromNotificationServer(); - } - - if (notificationType.payload == NotificationType.none && successfullyRemovedExistingTokens) { - // If we have successfully removed all tokens from the server, we'll remove the preference altogether - SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - prefs.remove(LocalSettings.inboxNotificationType.name); - debugPrint('Removed tokens from notification server'); - } else if (notificationType.payload == NotificationType.none && !successfullyRemovedExistingTokens) { - // If we failed to remove all tokens from the server, we'll set the preference to NotificationType.none - // The next time the app is opened, it will attempt to remove tokens from the server - showSnackbar(l10n.failedToDisablePushNotifications); - return setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); - } - - // If using local notifications, show a warning - if (notificationType.payload == NotificationType.local) { - bool res = false; - - await showThunderDialog( - context: context, - title: l10n.warning, - contentWidgetBuilder: (_) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - CommonMarkdownBody(body: l10n.notificationsWarningDialog), - const SizedBox(height: 5), - Align( - alignment: Alignment.centerLeft, - child: GestureDetector( - onTap: () => handleLink(context, url: 'https://dontkillmyapp.com/'), - child: Text( - 'https://dontkillmyapp.com/', - style: theme.textTheme.bodyMedium?.copyWith(color: Colors.blue), - ), - ), - ), - ], - ), - primaryButtonText: l10n.understandEnable, - onPrimaryButtonPressed: (dialogContext, _) { - res = true; - dialogContext.pop(); - }, - secondaryButtonText: l10n.cancel, - onSecondaryButtonPressed: (dialogContext) => dialogContext.pop(), - ); - - if (!res) return; - } - - // Check notifications permissions and enable them if needed - switch (notificationType.payload) { - case NotificationType.local: - case NotificationType.unifiedPush: - // We're on Android. Request notifications permissions if needed. This is a no-op if on SDK version < 33 - AndroidFlutterLocalNotificationsPlugin? androidFlutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); - - bool? areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.areNotificationsEnabled(); - - if (areAndroidNotificationsAllowed != true) { - areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.requestNotificationsPermission(); - if (areAndroidNotificationsAllowed != true) return showSnackbar(l10n.permissionDenied); - } - - // Permissions have been granted, so we can enable notifications - setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); - break; - case NotificationType.apn: - // We're on iOS. Request notifications permissions if needed. - IOSFlutterLocalNotificationsPlugin? iosFlutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); - - NotificationsEnabledOptions? notificationsEnabledOptions = await iosFlutterLocalNotificationsPlugin?.checkPermissions(); - - if (notificationsEnabledOptions?.isEnabled != true) { - bool? isEnabled = await iosFlutterLocalNotificationsPlugin?.requestPermissions(alert: true, badge: true, sound: true); - if (isEnabled != true) return showSnackbar(l10n.permissionDenied); - } - - setPreferences(LocalSettings.inboxNotificationType, notificationType.payload); - break; - default: - break; - } + bool success = await updateNotificationSettings( + context, + currentNotificationType: inboxNotificationType, + updatedNotificationType: notificationType.payload, + onUpdate: (NotificationType updatedNotificationType) { + setPreferences(LocalSettings.inboxNotificationType, updatedNotificationType); + }, + ); + + if (!success) showSnackbar(l10n.failedToUpdateNotificationSettings); + _initPreferences(); }, ); }, ), ), ), - SliverToBoxAdapter( - child: SettingsListTile( - icon: Icons.electrical_services_rounded, - description: l10n.pushNotificationServer, - subtitle: pushNotificationServer, - widget: const SizedBox( - height: 42.0, - child: Icon(Icons.chevron_right_rounded), - ), - onTap: () async { - showThunderDialog( - context: context, - title: l10n.pushNotificationServer, - contentWidgetBuilder: (_) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - CommonMarkdownBody(body: l10n.pushNotificationServerDescription), - const SizedBox(height: 32.0), - TextField( - textInputAction: TextInputAction.done, - keyboardType: TextInputType.url, - autocorrect: false, - controller: controller, - decoration: InputDecoration( - isDense: true, - border: const OutlineInputBorder(), - labelText: l10n.url, - hintText: THUNDER_SERVER_URL, + if (inboxNotificationType == NotificationType.unifiedPush || inboxNotificationType == NotificationType.apn) + SliverToBoxAdapter( + child: SettingsListTile( + icon: Icons.electrical_services_rounded, + description: l10n.pushNotificationServer, + subtitle: pushNotificationServer, + widget: const SizedBox( + height: 42.0, + child: Icon(Icons.chevron_right_rounded), + ), + onTap: () async { + showThunderDialog( + context: context, + title: l10n.pushNotificationServer, + contentWidgetBuilder: (_) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + CommonMarkdownBody(body: l10n.pushNotificationServerDescription), + const SizedBox(height: 32.0), + TextField( + textInputAction: TextInputAction.done, + keyboardType: TextInputType.url, + autocorrect: false, + controller: controller, + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder(), + labelText: l10n.url, + hintText: THUNDER_SERVER_URL, + ), + enableSuggestions: false, ), - enableSuggestions: false, - ), - ], - ); - }, - secondaryButtonText: l10n.cancel, - onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), - primaryButtonText: l10n.confirm, - onPrimaryButtonPressed: (dialogContext, _) { - setPreferences(LocalSettings.pushNotificationServer, controller.text); - Navigator.of(dialogContext).pop(); - }, - ); - }, + ], + ); + }, + secondaryButtonText: l10n.cancel, + onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), + primaryButtonText: l10n.confirm, + onPrimaryButtonPressed: (dialogContext, _) { + setPreferences(LocalSettings.pushNotificationServer, controller.text); + Navigator.of(dialogContext).pop(); + }, + ); + }, + ), ), - ), ], const SliverToBoxAdapter(child: SizedBox(height: 16.0)), SliverToBoxAdapter( From 79f02ce416d8959935ed33f58d72385bb648df85 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Tue, 23 Apr 2024 13:17:23 -0700 Subject: [PATCH 17/18] fixed issue where empty notification groups were being shown --- lib/notification/utils/local_notifications.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/notification/utils/local_notifications.dart b/lib/notification/utils/local_notifications.dart index 8346e80be..2ceed0a7c 100644 --- a/lib/notification/utils/local_notifications.dart +++ b/lib/notification/utils/local_notifications.dart @@ -75,7 +75,13 @@ Future pollRepliesAndShowNotifications() async { // Only handle messages that have arrived since the last time we polled final Iterable newReplies = getRepliesResponse.replies.where((CommentReplyView commentReplyView) => commentReplyView.commentReply.published.isAfter(lastPollTime)); - notifications.putIfAbsent(account, () => newReplies.toList()); + if (newReplies.isNotEmpty) notifications.putIfAbsent(account, () => newReplies.toList()); + } + + if (notifications.isEmpty) { + // Save our poll time + prefs.setString(_lastPollTimeId, DateTime.now().toString()); + return; } // Create a notification group for each account that has replies @@ -128,7 +134,7 @@ void backgroundFetchHeadlessTask(HeadlessTask task) async { Future initBackgroundFetch() async { await BackgroundFetch.configure( BackgroundFetchConfig( - minimumFetchInterval: 15, + minimumFetchInterval: 1, stopOnTerminate: false, startOnBoot: true, enableHeadless: true, @@ -138,7 +144,7 @@ Future initBackgroundFetch() async { requiresCharging: false, requiresDeviceIdle: false, // Uncomment this line (and set the minimumFetchInterval to 1) for quicker testing. - // forceAlarmManager: true, + forceAlarmManager: true, ), // This is the callback that handles background fetching while the app is running. (String taskId) async { From f8190f72750dec343604d38aa6e57da3fd951504 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Tue, 23 Apr 2024 13:18:28 -0700 Subject: [PATCH 18/18] revert back background task config --- lib/notification/utils/local_notifications.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/notification/utils/local_notifications.dart b/lib/notification/utils/local_notifications.dart index 2ceed0a7c..621f41068 100644 --- a/lib/notification/utils/local_notifications.dart +++ b/lib/notification/utils/local_notifications.dart @@ -134,7 +134,7 @@ void backgroundFetchHeadlessTask(HeadlessTask task) async { Future initBackgroundFetch() async { await BackgroundFetch.configure( BackgroundFetchConfig( - minimumFetchInterval: 1, + minimumFetchInterval: 15, stopOnTerminate: false, startOnBoot: true, enableHeadless: true, @@ -144,7 +144,7 @@ Future initBackgroundFetch() async { requiresCharging: false, requiresDeviceIdle: false, // Uncomment this line (and set the minimumFetchInterval to 1) for quicker testing. - forceAlarmManager: true, + // forceAlarmManager: true, ), // This is the callback that handles background fetching while the app is running. (String taskId) async {