diff --git a/ios/Podfile.lock b/ios/Podfile.lock index cd996d709..0946d5d4c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -32,6 +32,8 @@ PODS: - Flutter - 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): @@ -79,6 +81,7 @@ DEPENDENCIES: - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/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`) @@ -124,6 +127,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/permission_handler_apple/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: @@ -156,6 +161,7 @@ SPEC CHECKSUMS: path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 pointer_interceptor_ios: 9280618c0b2eeb80081a343924aa8ad756c21375 + push_ios: 2bd1b4d3f782209da1f571db1250af236957e807 share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index b515247c5..d10f779a8 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 */, @@ -628,6 +630,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; @@ -815,6 +818,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; @@ -840,6 +844,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; @@ -919,6 +924,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; @@ -1060,6 +1066,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; @@ -1193,6 +1200,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/core/enums/local_settings.dart b/lib/core/enums/local_settings.dart index 6167f8b17..2ab0b5dab 100644 --- a/lib/core/enums/local_settings.dart +++ b/lib/core/enums/local_settings.dart @@ -101,8 +101,8 @@ 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), - 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), + pushNotificationServer(name: 'setting_push_notification_server', key: 'pushNotificationServer', category: LocalSettingsCategories.general, subCategory: LocalSettingsSubCategories.notifications), /// -------------------------- Feed Post Related Settings -------------------------- // Compact Related Settings @@ -323,7 +323,8 @@ extension LocalizationExt on AppLocalizations { 'markPostAsReadOnScroll': markPostAsReadOnScroll, 'showInAppUpdateNotifications': showInAppUpdateNotifications, 'showUpdateChangelogs': showUpdateChangelogs, - 'enableInboxNotifications': enableInboxNotifications, + '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 8fe44004d..a05803518 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", @@ -499,6 +503,14 @@ "@disable": { "description": "Action for disabling something" }, + "disablePushNotifications": "Disable Push Notifications", + "@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)" @@ -563,7 +575,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" }, @@ -617,10 +629,18 @@ }, "failedToBlock": "Failed to block: {errorMessage}", "@failedToBlock": {}, + "failedToDisablePushNotifications": "Failed to disable push notifications", + "@failedToDisablePushNotifications": { + "description": "Error message when failed to disable push notifications." + }, "failedToLoadBlocks": "Could not load blocks: {errorMessage}", "@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" @@ -861,6 +881,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 +1083,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" @@ -1081,7 +1109,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" }, @@ -1253,6 +1281,22 @@ "@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" + }, + "pushNotificationServer": "Push Notification Server", + "@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", @@ -1875,6 +1919,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)" @@ -1927,10 +1975,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." @@ -1941,6 +2005,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 37b37937d..822e618f7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,46 +1,49 @@ +// Dart imports import 'dart:async'; import 'dart:io'; -import 'package:background_fetch/background_fetch.dart'; -import 'package:dart_ping_ios/dart_ping_ios.dart'; +// Flutter imports +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -// External Packages -import 'package:flutter_bloc/flutter_bloc.dart'; +// 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_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: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/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'; +import 'package:thunder/core/theme/bloc/theme_bloc.dart'; import 'package:thunder/instance/bloc/instance_bloc.dart'; - -// Internal Packages +import 'package:thunder/notification/notifications.dart'; +import 'package:thunder/notification/shared/notification_server.dart'; 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.dart'; import 'package:thunder/utils/preferences.dart'; late AppDatabase database; @@ -71,9 +74,6 @@ void main() async { // Setting SystemUIMode SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - // Load up preferences - SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; - await initializeDatabase(); // Clear image cache @@ -84,60 +84,64 @@ 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(); - const ThunderApp({super.key, required this.notificationsStream}); + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; + String? inboxNotificationType = prefs.getString(LocalSettings.inboxNotificationType.name); + + // 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 { + // 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'); + } + } + }); + } + + @override + void dispose() { + super.dispose(); + notificationsStreamController.close(); + } @override Widget build(BuildContext context) { @@ -156,7 +160,7 @@ class ThunderApp extends StatelessWidget { create: (context) => DeepLinksCubit(), ), BlocProvider( - create: (context) => NotificationsCubit(notificationsStream: notificationsStream), + create: (context) => NotificationsCubit(notificationsStream: notificationsStreamController.stream), ), BlocProvider( create: (context) => ThunderBloc(), @@ -252,67 +256,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/notification/enums/notification_type.dart b/lib/notification/enums/notification_type.dart new file mode 100644 index 000000000..6121d87e1 --- /dev/null +++ b/lib/notification/enums/notification_type.dart @@ -0,0 +1,28 @@ +// Package imports +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +// Project imports +import 'package:thunder/utils/global_context.dart'; + +enum NotificationType { + none, + local, + unifiedPush, + apn; + + @override + String toString() { + final l10n = AppLocalizations.of(GlobalContext.context)!; + + switch (this) { + case NotificationType.none: + return l10n.none; + case NotificationType.local: + return l10n.localNotifications; + case NotificationType.unifiedPush: + return l10n.unifiedPushNotifications; + case NotificationType.apn: + return l10n.applePushNotificationService; + } + } +} diff --git a/lib/notification/notifications.dart b/lib/notification/notifications.dart new file mode 100644 index 000000000..47746b738 --- /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/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'; +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..7cec93e72 --- /dev/null +++ b/lib/notification/shared/android_notification.dart @@ -0,0 +1,70 @@ +// 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. +/// +/// 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) { + // 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. 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, + Account? account, + String title = '', + String content = '', + String payload = '', +}) 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/notification/shared/notification_server.dart b/lib/notification/shared/notification_server.dart new file mode 100644 index 000000000..0c69d847f --- /dev/null +++ b/lib/notification/shared/notification_server.dart @@ -0,0 +1,76 @@ +// 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/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 String jwt, + required String instance, +}) async { + try { + final prefs = (await UserPreferences.instance).sharedPreferences; + String pushNotificationServer = prefs.getString(LocalSettings.pushNotificationServer.name) ?? THUNDER_SERVER_URL; + + // Send POST request to notification server + http.Response response = await http.post( + Uri.parse(pushNotificationServer), + headers: {'Content-Type': 'application/json; charset=UTF-8'}, + body: jsonEncode({ + 'type': type.name, + 'token': token, + 'jwt': jwt, + 'instance': instance, + }), + ); + + // Check if the request was successful + if (response.statusCode == 201) return true; + return false; + } catch (e) { + debugPrint(e.toString()); + return false; + } +} + +/// 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; + + 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(pushNotificationServer), + headers: {'Content-Type': 'application/json; charset=UTF-8'}, + body: jsonEncode({'jwts': jwts}), + ); + + // Check if the request was successful + if (response.statusCode == 200) return true; + return false; + } catch (e) { + return false; + } +} diff --git a/lib/notification/utils/apns.dart b/lib/notification/utils/apns.dart new file mode 100644 index 000000000..0867176e2 --- /dev/null +++ b/lib/notification/utils/apns.dart @@ -0,0 +1,77 @@ +// 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/notification/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 found, 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, jwt: 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, jwt: 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..621f41068 --- /dev/null +++ b/lib/notification/utils/local_notifications.dart @@ -0,0 +1,179 @@ +// 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)); + + 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 + 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}', + ); + } + } + + // 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/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/notification/utils/unified_push.dart b/lib/notification/utils/unified_push.dart new file mode 100644 index 000000000..860973099 --- /dev/null +++ b/lib/notification/utils/unified_push.dart @@ -0,0 +1,139 @@ +// 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/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'; +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, jwt: 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}', + ); + } + + // 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}', + ); + } + + 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/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 8fb54239b..2240f46b2 100644 --- a/lib/settings/pages/general_settings_page.dart +++ b/lib/settings/pages/general_settings_page.dart @@ -1,8 +1,7 @@ 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'; import 'package:lemmy_api_client/v3.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -10,24 +9,27 @@ 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'; 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/main.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'; 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'; import 'package:thunder/utils/constants.dart'; import 'package:thunder/utils/language/language.dart'; -import 'package:thunder/utils/links.dart'; import 'package:version/version.dart'; class GeneralSettingsPage extends StatefulWidget { @@ -83,10 +85,10 @@ 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; + /// 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; @@ -114,6 +116,12 @@ class _GeneralSettingsPageState extends State with SingleTi GlobalKey settingToHighlightKey = GlobalKey(); LocalSettings? settingToHighlight; + /// List of authenticated accounts. Used to determine if push notifications are enabled + List accounts = []; + + /// Controller for the push notification server URL + TextEditingController controller = TextEditingController(); + Future setPreferences(attribute, value) async { final prefs = (await UserPreferences.instance).sharedPreferences; @@ -193,9 +201,13 @@ 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.pushNotificationServer: + await prefs.setString(LocalSettings.pushNotificationServer.name, value); + setState(() => pushNotificationServer = value); break; case LocalSettings.imageCachingMode: @@ -216,6 +228,9 @@ class _GeneralSettingsPageState extends State with SingleTi void _initPreferences() async { final prefs = (await UserPreferences.instance).sharedPreferences; + // Get all currently active accounts + List accountList = await Account.accounts(); + setState(() { // Default Sorts and Listing try { @@ -250,16 +265,12 @@ 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); + pushNotificationServer = prefs.getString(LocalSettings.pushNotificationServer.name) ?? THUNDER_SERVER_URL; + controller.text = pushNotificationServer; - 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); + accounts = accountList; + }); } @override @@ -267,7 +278,6 @@ class _GeneralSettingsPageState extends State with SingleTi super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) async { _initPreferences(); - await checkAndroidNotificationStatus(); if (widget.settingToHighlight != null) { setState(() => settingToHighlight = widget.settingToHighlight); @@ -662,87 +672,130 @@ 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( + child: ListOption( 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( + 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, + 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, + 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, + ), + ] + : [ + ListPickerItem( + 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 { + if (notificationType.payload == inboxNotificationType) return; + + bool success = await updateNotificationSettings( + context, + currentNotificationType: inboxNotificationType, + updatedNotificationType: notificationType.payload, + onUpdate: (NotificationType updatedNotificationType) { + setPreferences(LocalSettings.inboxNotificationType, updatedNotificationType); + }, + ); + + if (!success) showSnackbar(l10n.failedToUpdateNotificationSettings); + _initPreferences(); + }, + ); + }, + ), + ), + ), + 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.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), + 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, + ], + ); + }, + secondaryButtonText: l10n.cancel, + onSecondaryButtonPressed: (dialogContext) => Navigator.of(dialogContext).pop(), + primaryButtonText: l10n.confirm, onPrimaryButtonPressed: (dialogContext, _) { - res = true; - dialogContext.pop(); + setPreferences(LocalSettings.pushNotificationServer, controller.text); + Navigator.of(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(); - } - - // 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(); - } - }, - 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( diff --git a/lib/shared/picker_item.dart b/lib/shared/picker_item.dart index ae7dc7b5c..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,7 +52,7 @@ class PickerItem extends StatelessWidget { style: (textTheme?.bodyMedium ?? theme.textTheme.bodyMedium)?.copyWith( color: (textTheme?.bodyMedium ?? theme.textTheme.bodyMedium)?.color?.withOpacity(0.5), ), - softWrap: false, + softWrap: softWrap, overflow: TextOverflow.fade, ) : null, diff --git a/lib/thunder/bloc/thunder_bloc.dart b/lib/thunder/bloc/thunder_bloc.dart index 4b66a1d50..35372e8bf 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/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'; @@ -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); NameThickness userFullNameUserNameThickness = NameThickness.values.byName(prefs.getString(LocalSettings.userFullNameUserNameThickness.name) ?? NameThickness.normal.name); @@ -274,7 +275,7 @@ class ThunderBloc extends Bloc { markPostReadOnScroll: markPostReadOnScroll, showInAppUpdateNotification: showInAppUpdateNotification, showUpdateChangelogs: showUpdateChangelogs, - enableInboxNotifications: enableInboxNotifications, + inboxNotificationType: inboxNotificationType, appLanguageCode: appLanguageCode, userSeparator: userSeparator, userFullNameUserNameThickness: userFullNameUserNameThickness, diff --git a/lib/thunder/bloc/thunder_state.dart b/lib/thunder/bloc/thunder_state.dart index e48c1c7bc..3500fa1ea 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.userFullNameUserNameThickness = NameThickness.normal, @@ -183,7 +183,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 NameThickness userFullNameUserNameThickness; @@ -341,7 +341,7 @@ class ThunderState extends Equatable { bool? markPostReadOnScroll, bool? showInAppUpdateNotification, bool? showUpdateChangelogs, - bool? enableInboxNotifications, + NotificationType? inboxNotificationType, bool? scoreCounters, FullNameSeparator? userSeparator, NameThickness? userFullNameUserNameThickness, @@ -493,7 +493,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, @@ -652,7 +652,7 @@ class ThunderState extends Equatable { disableFeedFab, showInAppUpdateNotification, showUpdateChangelogs, - enableInboxNotifications, + inboxNotificationType, userSeparator, userFullNameUserNameThickness, userFullNameUserNameColor, diff --git a/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart b/lib/thunder/cubits/notifications_cubit/notifications_cubit.dart index 3965e7440..9fc2fb4ca 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.dart'; + +import 'package:thunder/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/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, }); } 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.dart b/lib/utils/notifications.dart deleted file mode 100644 index a444c7039..000000000 --- a/lib/utils/notifications.dart +++ /dev/null @@ -1,129 +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/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 (prefs.getBool(LocalSettings.enableInboxNotifications.name) != true) 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/preferences.dart b/lib/utils/preferences.dart index 2da5a4e10..5abfde13b 100644 --- a/lib/utils/preferences.dart +++ b/lib/utils/preferences.dart @@ -2,6 +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/notification/enums/notification_type.dart'; import 'package:thunder/core/singletons/preferences.dart'; Future performSharedPreferencesMigration() async { @@ -29,4 +30,11 @@ Future performSharedPreferencesMigration() async { await prefs.setString(LocalSettings.userFullNameUserNameColor.name, NameColor.themePrimary); } } + + // Migrate the enableInboxNotifications setting, if found. + bool? legacyEnableInboxNotifications = prefs.getBool('setting_enable_inbox_notifications'); + if (legacyEnableInboxNotifications != null) { + await prefs.remove('setting_enable_inbox_notifications'); + await prefs.setString(LocalSettings.inboxNotificationType.name, legacyEnableInboxNotifications ? NotificationType.local.name : NotificationType.none.name); + } } diff --git a/pubspec.lock b/pubspec.lock index 849a0b3af..4f82a34e6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -345,6 +345,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: @@ -1393,6 +1401,48 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.3" + push: + dependency: "direct main" + description: + path: push + ref: "722c5e8541e1e5c91c56743b94d07f51f26fd2c5" + resolved-ref: "722c5e8541e1e5c91c56743b94d07f51f26fd2c5" + url: "https://github.com/hjiangsu/push.git" + source: git + version: "2.1.0" + push_android: + dependency: "direct overridden" + description: + path: push_android + ref: "70269bd08a06c4f34f6f192a2d46127ae48479a5" + resolved-ref: "70269bd08a06c4f34f6f192a2d46127ae48479a5" + url: "https://github.com/hjiangsu/push.git" + source: git + 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" recase: dependency: transitive description: @@ -1727,6 +1777,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 19c1e99e3..f6439e5d3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -104,6 +104,12 @@ dependencies: gal: ^2.2.0 smooth_highlight: ^0.1.1 visibility_detector: ^0.4.0+2 + push: + git: + url: https://github.com/hjiangsu/push.git + ref: 722c5e8541e1e5c91c56743b94d07f51f26fd2c5 + path: push + unifiedpush: ^5.0.1 flutter_sharing_intent: ^1.1.1 drift: ^2.16.0 sqlite3_flutter_libs: ^0.5.20 @@ -119,6 +125,13 @@ dev_dependencies: ref: master drift_dev: ^2.16.0 +dependency_overrides: + push_android: + git: + url: https://github.com/hjiangsu/push.git + ref: 70269bd08a06c4f34f6f192a2d46127ae48479a5 + path: push_android + # The following section is specific to Flutter packages. flutter: uses-material-design: true