From f2b6f1af45dac5374138cdb98b6093a973da93db Mon Sep 17 00:00:00 2001 From: Micah Morrison Date: Wed, 17 Jan 2024 13:20:07 -0500 Subject: [PATCH] Add ability for local push notifications on Android (#1000) * Add inbox message notifications * Improve appearance of notifications - Show more text in expanded body - Show post name and community - Render markdown/html * Do not show unformatted markdown * Show notification experimental warning * added release build configuration * Remove multiDexEnabled due to SDK >= 21 * Refactor some code * Make setState more clear --------- Co-authored-by: Hamlet Jiang Su --- android/app/build.gradle | 21 ++- android/app/proguard-rules.pro | 32 +++++ android/app/src/debug/AndroidManifest.xml | 4 + android/app/src/main/AndroidManifest.xml | 6 +- android/app/src/main/res/raw/keep.xml | 2 + android/build.gradle | 14 +- ios/Podfile.lock | 12 ++ lib/core/enums/full_name_separator.dart | 21 +-- lib/core/enums/local_settings.dart | 2 + lib/l10n/app_en.arb | 28 ++++ lib/main.dart | 114 ++++++++++++++++ lib/routes.dart | 6 +- lib/settings/pages/general_settings_page.dart | 111 +++++++++++++++- lib/thunder/bloc/thunder_bloc.dart | 2 + lib/thunder/bloc/thunder_state.dart | 5 + lib/thunder/pages/thunder_page.dart | 23 ++-- lib/utils/notifications.dart | 122 ++++++++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 48 +++++++ pubspec.yaml | 2 + 20 files changed, 546 insertions(+), 31 deletions(-) create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/res/raw/keep.xml create mode 100644 lib/utils/notifications.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 5602593fc..f869623f3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -30,10 +30,13 @@ if (keystorePropertiesFile.exists()) { android { namespace "com.hjiangsu.thunder" - compileSdkVersion flutter.compileSdkVersion + // Use variable for version requested by background_fetch and flutter_local_notifications + compileSdkVersion rootProject.ext.compileSdkVersion ndkVersion flutter.ndkVersion compileOptions { + // The following line is required by flutter_local_notifications + coreLibraryDesugaringEnabled true sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } @@ -52,7 +55,8 @@ android { // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. minSdkVersion 21 - targetSdkVersion flutter.targetSdkVersion + // Use variable for version requested by background_fetch and flutter_local_notifications + targetSdkVersion rootProject.ext.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName } @@ -68,9 +72,9 @@ android { buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - // signingConfig signingConfigs.debug + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" signingConfig signingConfigs.release } } @@ -80,4 +84,9 @@ flutter { source '../..' } -dependencies {} +dependencies { + // The following 3 dependencies are required by flutter_local_notifications + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.2' + implementation 'androidx.window:window:1.0.0' + implementation 'androidx.window:window-java:1.0.0' +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 000000000..ea6dd795b --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,32 @@ +##---------------Begin: proguard configuration for Gson ---------- +# Gson uses generic type information stored in a class file when working with fields. Proguard +# removes such information by default, so configure it to keep all of it. +-keepattributes Signature + +# For using GSON @Expose annotation +-keepattributes *Annotation* + +# Gson specific classes +-dontwarn sun.misc.** +#-keep class com.google.gson.stream.** { *; } + +# Application classes that will be serialized/deserialized over Gson +-keep class com.google.gson.examples.android.model.** { ; } + +# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, +# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) +-keep class * extends com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# Prevent R8 from leaving Data object members always null +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} + +# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken +-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken + +##---------------End: proguard configuration for Gson ---------- \ No newline at end of file diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 399f6981d..6776991b9 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,8 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8b2279cb8..e98021116 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ - + @@ -6,8 +8,10 @@ + + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index f7eb7f63c..714812d9d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,12 +1,20 @@ buildscript { ext.kotlin_version = '1.7.10' + // Versions recommended/needed by flutter_local_notifications and background_fetch + // These can be upgraded over time. + ext { + compileSdkVersion = 33 + targetSdkVersion = 33 + appCompatVersion = "1.6.1" + } repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.3.0' + // flutter_local_notifications requires gradle version 7.3.1 + classpath 'com.android.tools.build:gradle:7.3.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -15,6 +23,10 @@ allprojects { repositories { google() mavenCentral() + // The following section is required by background_fetch + maven { + url "${project(':background_fetch').projectDir}/libs" + } } } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 4be5b8661..2c1b2f11b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - background_fetch (1.2.1): + - Flutter - device_info_plus (0.0.1): - Flutter - Flutter (1.0.0) @@ -10,6 +12,8 @@ PODS: - Flutter - flutter_keyboard_visibility (0.0.1): - Flutter + - flutter_local_notifications (0.0.1): + - Flutter - flutter_native_splash (0.0.1): - Flutter - FMDB (2.7.5): @@ -45,12 +49,14 @@ PODS: - Flutter DEPENDENCIES: + - background_fetch (from `.symlinks/plugins/background_fetch/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) - flutter_custom_tabs_ios (from `.symlinks/plugins/flutter_custom_tabs_ios/ios`) - flutter_file_dialog (from `.symlinks/plugins/flutter_file_dialog/ios`) - flutter_icmp_ping (from `.symlinks/plugins/flutter_icmp_ping/ios`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - gal (from `.symlinks/plugins/gal/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) @@ -70,6 +76,8 @@ SPEC REPOS: - FMDB EXTERNAL SOURCES: + background_fetch: + :path: ".symlinks/plugins/background_fetch/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" Flutter: @@ -82,6 +90,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_icmp_ping/ios" flutter_keyboard_visibility: :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" gal: @@ -110,12 +120,14 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/webview_flutter_wkwebview/ios" SPEC CHECKSUMS: + background_fetch: 896944864b038d2837fc750d470e9841e1e6a363 device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 flutter_custom_tabs_ios: 62439c843b2691aae516fd50119a01eb9755fff7 flutter_file_dialog: 4c014a45b105709a27391e266c277d7e588e9299 flutter_icmp_ping: 2b159955eee0c487c766ad83fec224ae35e7c935 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 + flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1 diff --git a/lib/core/enums/full_name_separator.dart b/lib/core/enums/full_name_separator.dart index 582424966..01e25d644 100644 --- a/lib/core/enums/full_name_separator.dart +++ b/lib/core/enums/full_name_separator.dart @@ -11,25 +11,28 @@ enum FullNameSeparator { const FullNameSeparator(this.label); } -String generateUserFullName(BuildContext context, name, instance) { - final ThunderState thunderState = context.read().state; - return switch (thunderState.userSeparator) { +String generateUserFullName(BuildContext? context, name, instance, {FullNameSeparator? userSeparator}) { + assert(context != null || userSeparator != null); + userSeparator ??= context!.read().state.userSeparator; + return switch (userSeparator) { FullNameSeparator.dot => '$name · $instance', FullNameSeparator.at => '$name@$instance', }; } -String generateUserFullNameSuffix(BuildContext context, instance) { - final ThunderState thunderState = context.read().state; - return switch (thunderState.userSeparator) { +String generateUserFullNameSuffix(BuildContext? context, instance, {FullNameSeparator? userSeparator}) { + assert(context != null || userSeparator != null); + userSeparator ??= context!.read().state.userSeparator; + return switch (userSeparator) { FullNameSeparator.dot => ' · $instance', FullNameSeparator.at => '@$instance', }; } -String generateCommunityFullName(BuildContext context, name, instance) { - final ThunderState thunderState = context.read().state; - return switch (thunderState.communitySeparator) { +String generateCommunityFullName(BuildContext? context, name, instance, {FullNameSeparator? communitySeparator}) { + assert(context != null || communitySeparator != null); + communitySeparator ??= context!.read().state.communitySeparator; + return switch (communitySeparator) { FullNameSeparator.dot => '$name · $instance', FullNameSeparator.at => '$name@$instance', }; diff --git a/lib/core/enums/local_settings.dart b/lib/core/enums/local_settings.dart index 5e2fba95a..cbce040f4 100644 --- a/lib/core/enums/local_settings.dart +++ b/lib/core/enums/local_settings.dart @@ -20,6 +20,7 @@ enum LocalSettings { useDisplayNamesForUsers(name: 'setting_use_display_names_for_users', key: 'showUserDisplayNames'), markPostAsReadOnMediaView(name: 'setting_general_mark_post_read_on_media_view', key: 'markPostAsReadOnMediaView'), showInAppUpdateNotification(name: 'setting_notifications_show_inapp_update', key: 'showInAppUpdateNotifications'), + enableInboxNotifications(name: 'setting_enable_inbox_notifications', key: 'enableInboxNotifications'), scoreCounters(name: 'setting_score_counters', key: "showScoreCounters"), appLanguageCode(name: 'setting_app_language_code', key: 'appLanguage'), @@ -159,6 +160,7 @@ extension LocalizationExt on AppLocalizations { 'showUserDisplayNames': showUserDisplayNames, 'markPostAsReadOnMediaView': markPostAsReadOnMediaView, 'showInAppUpdateNotifications': showInAppUpdateNotifications, + 'enableInboxNotifications': enableInboxNotifications, 'showScoreCounters': showScoreCounters, 'appLanguage': appLanguage, 'compactView': compactView, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 02a5687e4..ddec9e3ee 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -71,6 +71,10 @@ "@back": {}, "backButton": "Back button", "@backButton": {}, + "backgroundCheckWarning": "Note that notification checks will consume additional battery", + "@backgroundCheckWarning": { + "description": "Warning for enabling notifications" + }, "backToTop": "Back To Top", "@backToTop": {}, "base": "Base", @@ -357,6 +361,10 @@ "@dimReadPosts": { "description": "Description of the effect on read posts." }, + "disable": "Disable", + "@disable": { + "description": "Action for disabling something" + }, "dismissRead": "Dismiss Read", "@dismissRead": {}, "displayUserScore": "Display User Scores (Karma).", @@ -393,6 +401,10 @@ "@enableFeedFab": { "description": "Enable the Floating Action Button for the feed" }, + "enableInboxNotifications": "Enable Inbox Notifications (Experimental)", + "@enableInboxNotifications": { + "description": "Setting name for inbox notifications" + }, "enableFloatingButtonOnFeeds": "Enable Floating Button On Feeds", "@enableFloatingButtonOnFeeds": { "description": "Setting for enable floating button on feeds" @@ -739,6 +751,14 @@ "@notificationsBehaviourSettings": { "description": "Subcategory in Setting -> General" }, + "notificationsNotAllowed": "Notifications are not allowed for Thunder in system settings", + "@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": { + "description": "The content of the warning dialog for the notifications feature" + }, "off": "off", "@off": {}, "ok": "OK", @@ -1265,6 +1285,10 @@ "@unblockInstance": { "description": "Tooltip for unblocking an instance" }, + "understandEnable": "I Understand, Enable", + "@understandEnable": { + "description": "Action for acknowledging and enabling something" + }, "unexpectedError": "Unexpected Error", "@unexpectedError": {}, "unsubscribe": "Unsubscribe", @@ -1333,6 +1357,10 @@ "@visitInstance": {}, "visitUserProfile": "Visit User Profile", "@visitUserProfile": {}, + "warning": "Warning", + "@warning": { + "description": "Heading for warning dialogs" + }, "xDownvotes": "{x} downvotes", "@xDownvotes": {}, "xScore": "{x} score", diff --git a/lib/main.dart b/lib/main.dart index aacbc723f..f882fb525 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,7 @@ +import 'dart:async'; import 'dart:io'; +import 'package:background_fetch/background_fetch.dart'; import 'package:dart_ping_ios/dart_ping_ios.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -7,12 +9,14 @@ import 'package:flutter/services.dart'; // External Packages import 'package:flutter_bloc/flutter_bloc.dart'; import "package:flutter_displaymode/flutter_displaymode.dart"; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:l10n_esperanto/l10n_esperanto.dart'; import 'package:overlay_support/overlay_support.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:thunder/account/bloc/account_bloc.dart'; import 'package:thunder/community/bloc/anonymous_subscriptions_bloc.dart'; import 'package:thunder/community/bloc/community_bloc.dart'; @@ -32,6 +36,7 @@ import 'package:thunder/user/bloc/user_bloc.dart'; import 'package:thunder/utils/cache.dart'; import 'package:thunder/utils/global_context.dart'; import 'package:flutter/foundation.dart'; +import 'package:thunder/utils/notifications.dart'; void main() async { WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); @@ -51,12 +56,38 @@ void main() async { DartPingIOS.register(); } + // 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: onDidReceiveNotificationResponse); + + // 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?.payload == repliesGroupKey) { + thunderPageController = PageController(initialPage: 3); + } + + // Initialize background fetch (this is async and can go run on its own). + final SharedPreferences prefs = (await UserPreferences.instance).sharedPreferences; + if (!kIsWeb && Platform.isAndroid && (prefs.getBool(LocalSettings.enableInboxNotifications.name) ?? false)) { + initBackgroundFetch(); + } + final String initialInstance = (await UserPreferences.instance).sharedPreferences.getString(LocalSettings.currentAnonymousInstance.name) ?? 'lemmy.ml'; LemmyClient.instance.changeBaseUrl(initialInstance); runApp(const ThunderApp()); + // 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 StatelessWidget { @@ -172,3 +203,86 @@ class ThunderApp extends StatelessWidget { ); } } + +// ---------------- START LOCAL NOTIFICATION STUFF ---------------- // + +/// This seems to a notification callback handler that is only need for iOS. +void onDidReceiveLocalNotification(int id, String? title, String? body, String? payload) {} + +/// This is the notification handler that runs when a notification is tapped while the app is running. +void onDidReceiveNotificationResponse(NotificationResponse details) { + switch (details.payload) { + case repliesGroupKey: + // Navigate to the inbox page + thunderPageController.jumpToPage(3); + break; + default: + break; + } +} + +// ---------------- END LOCAL NOTIFICATION STUFF ---------------- // + +// ---------------- 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/routes.dart b/lib/routes.dart index 282a76256..233e23b90 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -20,19 +20,21 @@ import 'package:thunder/settings/settings.dart'; import 'package:thunder/thunder/thunder.dart'; import 'package:thunder/user/pages/user_settings_page.dart'; +PageController thunderPageController = PageController(initialPage: 0); + final GoRouter router = GoRouter( debugLogDiagnostics: true, routes: [ GoRoute( name: 'home', path: '/', - builder: (BuildContext context, GoRouterState state) => const Thunder(), + builder: (BuildContext context, GoRouterState state) => Thunder(pageController: thunderPageController), routes: const [], ), GoRoute( name: 'settings', path: '/settings', - builder: (BuildContext context, GoRouterState state) => SettingsPage(), + builder: (BuildContext context, GoRouterState state) => const SettingsPage(), routes: [ GoRoute( name: 'general', diff --git a/lib/settings/pages/general_settings_page.dart b/lib/settings/pages/general_settings_page.dart index c32dbd6ab..bb544c1d3 100644 --- a/lib/settings/pages/general_settings_page.dart +++ b/lib/settings/pages/general_settings_page.dart @@ -1,5 +1,7 @@ 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'; @@ -12,15 +14,18 @@ import 'package:thunder/core/enums/full_name_separator.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/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/dialogs.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'; class GeneralSettingsPage extends StatefulWidget { const GeneralSettingsPage({super.key}); @@ -66,6 +71,12 @@ class _GeneralSettingsPageState extends State with SingleTi /// When enabled, an app update notification will be shown when an update is available bool showInAppUpdateNotification = false; + /// When enabled, system-level notifications will be displayed for new inbox messages + bool enableInboxNotifications = false; + + /// Not a setting, but tracks whether Android is allowing Thunder to send notifications + bool? areAndroidNotificationsAllowed = false; + /// When enabled, authors and community names will be tappable when in compact view bool tappableAuthorCommunity = false; @@ -169,6 +180,10 @@ class _GeneralSettingsPageState extends State with SingleTi await prefs.setBool(LocalSettings.showInAppUpdateNotification.name, value); setState(() => showInAppUpdateNotification = value); break; + case LocalSettings.enableInboxNotifications: + await prefs.setBool(LocalSettings.enableInboxNotifications.name, value); + setState(() => enableInboxNotifications = value); + break; case LocalSettings.userFormat: await prefs.setString(LocalSettings.userFormat.name, value); @@ -221,13 +236,25 @@ class _GeneralSettingsPageState extends State with SingleTi communitySeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.communityFormat.name) ?? FullNameSeparator.dot.name); showInAppUpdateNotification = prefs.getBool(LocalSettings.showInAppUpdateNotification.name) ?? false; + enableInboxNotifications = prefs.getBool(LocalSettings.enableInboxNotifications.name) ?? false; }); } + Future checkAndroidNotificationStatus() async { + // Check whether Android is currently allowing Thunder to send notifications + final AndroidFlutterLocalNotificationsPlugin? androidFlutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); + final bool? areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.areNotificationsEnabled(); + setState(() => this.areAndroidNotificationsAllowed = areAndroidNotificationsAllowed); + } + @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _initPreferences()); + WidgetsBinding.instance.addPostFrameCallback((_) async { + _initPreferences(); + await checkAndroidNotificationStatus(); + }); } @override @@ -621,6 +648,88 @@ class _GeneralSettingsPageState extends State with SingleTi ), ), ), + if (!kIsWeb && Platform.isAndroid) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ToggleOption( + description: l10n.enableInboxNotifications, + value: enableInboxNotifications, + iconEnabled: Icons.notifications_on_rounded, + iconDisabled: Icons.notifications_off_rounded, + onToggle: (bool value) async { + // Show a warning message about the experimental nature of this feature. + // This message is specific to Android. + if (!kIsWeb && Platform.isAndroid && value) { + bool res = false; + await showThunderDialog( + context: context, + title: l10n.warning, + contentWidgetBuilder: (_) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.notificationsWarningDialog), + const SizedBox(height: 10), + Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + onTap: () => handleLink(context, url: 'https://dontkillmyapp.com/'), + child: Text( + 'https://dontkillmyapp.com/', + style: theme.textTheme.bodyMedium?.copyWith(color: Colors.blue), + ), + ), + ), + ], + ), + primaryButtonText: l10n.understandEnable, + onPrimaryButtonPressed: (dialogContext, _) { + res = true; + dialogContext.pop(); + }, + secondaryButtonText: l10n.disable, + onSecondaryButtonPressed: (dialogContext) => dialogContext.pop(), + ); + + // The user chose not to enable the feature + if (!res) return; + } + + setPreferences(LocalSettings.enableInboxNotifications, value); + + if (!kIsWeb && Platform.isAndroid && value) { + // We're on Android. Request notifications permissions if needed. + // This is a no-op if on SDK version < 33 + final AndroidFlutterLocalNotificationsPlugin? androidFlutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin().resolvePlatformSpecificImplementation(); + + areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.areNotificationsEnabled(); + if (areAndroidNotificationsAllowed != true) { + areAndroidNotificationsAllowed = await androidFlutterLocalNotificationsPlugin?.requestNotificationsPermission(); + } + + // 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, + ), + ), + ), const SliverToBoxAdapter(child: SizedBox(height: 16.0)), SliverToBoxAdapter( diff --git a/lib/thunder/bloc/thunder_bloc.dart b/lib/thunder/bloc/thunder_bloc.dart index 38aa946cf..a28bcf7f3 100644 --- a/lib/thunder/bloc/thunder_bloc.dart +++ b/lib/thunder/bloc/thunder_bloc.dart @@ -111,6 +111,7 @@ class ThunderBloc extends Bloc { bool useDisplayNames = prefs.getBool(LocalSettings.useDisplayNamesForUsers.name) ?? true; bool markPostReadOnMediaView = prefs.getBool(LocalSettings.markPostAsReadOnMediaView.name) ?? false; bool showInAppUpdateNotification = prefs.getBool(LocalSettings.showInAppUpdateNotification.name) ?? false; + bool enableInboxNotifications = prefs.getBool(LocalSettings.enableInboxNotifications.name) ?? false; String? appLanguageCode = prefs.getString(LocalSettings.appLanguageCode.name) ?? 'en'; FullNameSeparator userSeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.userFormat.name) ?? FullNameSeparator.at.name); FullNameSeparator communitySeparator = FullNameSeparator.values.byName(prefs.getString(LocalSettings.communityFormat.name) ?? FullNameSeparator.dot.name); @@ -240,6 +241,7 @@ class ThunderBloc extends Bloc { useDisplayNames: useDisplayNames, markPostReadOnMediaView: markPostReadOnMediaView, showInAppUpdateNotification: showInAppUpdateNotification, + enableInboxNotifications: enableInboxNotifications, appLanguageCode: appLanguageCode, userSeparator: userSeparator, communitySeparator: communitySeparator, diff --git a/lib/thunder/bloc/thunder_state.dart b/lib/thunder/bloc/thunder_state.dart index 47995b46b..4c25f61db 100644 --- a/lib/thunder/bloc/thunder_state.dart +++ b/lib/thunder/bloc/thunder_state.dart @@ -30,6 +30,7 @@ class ThunderState extends Equatable { this.markPostReadOnMediaView = false, this.disableFeedFab = false, this.showInAppUpdateNotification = false, + this.enableInboxNotifications = false, this.scoreCounters = false, this.userSeparator = FullNameSeparator.at, this.communitySeparator = FullNameSeparator.dot, @@ -157,6 +158,7 @@ class ThunderState extends Equatable { final bool markPostReadOnMediaView; final bool disableFeedFab; final bool showInAppUpdateNotification; + final bool enableInboxNotifications; final String? appLanguageCode; final FullNameSeparator userSeparator; final FullNameSeparator communitySeparator; @@ -292,6 +294,7 @@ class ThunderState extends Equatable { bool? useDisplayNames, bool? markPostReadOnMediaView, bool? showInAppUpdateNotification, + bool? enableInboxNotifications, bool? scoreCounters, FullNameSeparator? userSeparator, FullNameSeparator? communitySeparator, @@ -419,6 +422,7 @@ class ThunderState extends Equatable { markPostReadOnMediaView: markPostReadOnMediaView ?? this.markPostReadOnMediaView, disableFeedFab: disableFeedFab, showInAppUpdateNotification: showInAppUpdateNotification ?? this.showInAppUpdateNotification, + enableInboxNotifications: enableInboxNotifications ?? this.enableInboxNotifications, scoreCounters: scoreCounters ?? this.scoreCounters, appLanguageCode: appLanguageCode ?? this.appLanguageCode, userSeparator: userSeparator ?? this.userSeparator, @@ -554,6 +558,7 @@ class ThunderState extends Equatable { markPostReadOnMediaView, disableFeedFab, showInAppUpdateNotification, + enableInboxNotifications, userSeparator, communitySeparator, diff --git a/lib/thunder/pages/thunder_page.dart b/lib/thunder/pages/thunder_page.dart index 48c5b0ae1..8576ebd89 100644 --- a/lib/thunder/pages/thunder_page.dart +++ b/lib/thunder/pages/thunder_page.dart @@ -57,7 +57,9 @@ import 'package:thunder/utils/navigate_user.dart'; String? currentIntent; class Thunder extends StatefulWidget { - const Thunder({super.key}); + final PageController pageController; + + const Thunder({super.key, required this.pageController}); @override State createState() => _ThunderState(); @@ -67,8 +69,6 @@ class _ThunderState extends State { int selectedPageIndex = 0; int appExitCounter = 0; - PageController pageController = PageController(initialPage: 0); - bool hasShownUpdateDialog = false; bool _isFabOpen = false; @@ -87,6 +87,8 @@ class _ThunderState extends State { void initState() { super.initState(); + selectedPageIndex = widget.pageController.initialPage; + // Listen for callbacks from Android native code if (!kIsWeb && Platform.isAndroid) { const MethodChannel('com.hjiangsu.thunder/method_channel').setMethodCallHandler((MethodCall call) { @@ -106,7 +108,6 @@ class _ThunderState extends State { @override void dispose() { - pageController.dispose(); textIntentDataStreamSubscription.cancel(); mediaIntentDataStreamSubscription.cancel(); super.dispose(); @@ -205,9 +206,9 @@ class _ThunderState extends State { selectedPageIndex = 0; if (reduceAnimations) { - pageController.jumpToPage(selectedPageIndex); + widget.pageController.jumpToPage(selectedPageIndex); } else { - pageController.animateToPage(selectedPageIndex, duration: const Duration(milliseconds: 500), curve: Curves.ease); + widget.pageController.animateToPage(selectedPageIndex, duration: const Duration(milliseconds: 500), curve: Curves.ease); } }); return Future.value(false); @@ -446,9 +447,9 @@ class _ThunderState extends State { Navigator.of(context).pop(); if (reduceAnimations) { - pageController.jumpToPage(2); + widget.pageController.jumpToPage(2); } else { - pageController.animateToPage(2, duration: const Duration(milliseconds: 500), curve: Curves.ease); + widget.pageController.animateToPage(2, duration: const Duration(milliseconds: 500), curve: Curves.ease); } }, ) @@ -469,9 +470,9 @@ class _ThunderState extends State { selectedPageIndex = index; if (reduceAnimations) { - pageController.jumpToPage(index); + widget.pageController.jumpToPage(index); } else { - pageController.animateToPage(index, duration: const Duration(milliseconds: 500), curve: Curves.ease); + widget.pageController.animateToPage(index, duration: const Duration(milliseconds: 500), curve: Curves.ease); } }); }, @@ -520,7 +521,7 @@ class _ThunderState extends State { } return PageView( - controller: pageController, + controller: widget.pageController, onPageChanged: (index) => setState(() => selectedPageIndex = index), physics: const NeverScrollableScrollPhysics(), children: [ diff --git a/lib/utils/notifications.dart b/lib/utils/notifications.dart new file mode 100644 index 000000000..0fb1bc61f --- /dev/null +++ b/lib/utils/notifications.dart @@ -0,0 +1,122 @@ +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:thunder/account/models/account.dart'; +import 'package:thunder/core/auth/helpers/fetch_account.dart'; +import 'package:thunder/core/enums/full_name_separator.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/utils/instance.dart'; +import 'package:markdown/markdown.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; + + 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 htmlComment = markdownToHtml(commentReplyView.comment.content); + final String plaintextComment = parse(parse(htmlComment).body?.text).documentElement?.text ?? commentReplyView.comment.content; + + 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, // In the future, this could be a specific message ID for deep navigation + ); + + // 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/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 45087ae1a..d762a24d2 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,6 +8,7 @@ import Foundation import device_info_plus import dynamic_color import file_selector_macos +import flutter_local_notifications import gal import package_info_plus import path_provider_foundation @@ -20,6 +21,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 59fac68fb..02dba8b51 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -73,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + background_fetch: + dependency: "direct main" + description: + name: background_fetch + sha256: f70b28a0f7a3156195e9742229696f004ea3bf10f74039b7bf4c78a74fbda8a4 + url: "https://pub.dev" + source: hosted + version: "1.2.1" black_hole_flutter: dependency: transitive description: @@ -313,6 +321,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.4" + dbus: + dependency: transitive + description: + name: dbus + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + url: "https://pub.dev" + source: hosted + version: "0.7.10" dev_build: dependency: transitive description: @@ -638,6 +654,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: bb5cd63ff7c91d6efe452e41d0d0ae6348925c82eafd10ce170ef585ea04776e + url: "https://pub.dev" + source: hosted + version: "16.2.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "33f741ef47b5f63cc7f78fe75eeeac7e19f171ff3c3df054d84c1e38bedb6a03" + url: "https://pub.dev" + source: hosted + version: "4.0.0+1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "7cf643d6d5022f3baed0be777b0662cce5919c0a7b86e700299f22dc4ae660ef" + url: "https://pub.dev" + source: hosted + version: "7.0.0+1" flutter_localizations: dependency: "direct main" description: flutter @@ -1511,6 +1551,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + timezone: + dependency: transitive + description: + name: timezone + sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0" + url: "https://pub.dev" + source: hosted + version: "0.9.2" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1ea2edfb7..44da95a4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -104,6 +104,8 @@ dependencies: sqflite_common_ffi_web: ^0.4.2 jovial_svg: ^1.1.19 flutter_displaymode: ^0.6.0 + flutter_local_notifications: ^16.2.0 + background_fetch: ^1.2.1 gal: ^2.2.0 dev_dependencies: