From 4ec79e09e004467e94d45bd4016cb5871cc09ee0 Mon Sep 17 00:00:00 2001 From: Lea Date: Wed, 1 Feb 2023 22:35:16 +0100 Subject: [PATCH] Implement background notifications, I think --- android/app/src/main/AndroidManifest.xml | 8 ++ lib/api/api.dart | 21 +++- lib/main.dart | 3 + lib/notifications/tasks.dart | 116 ++++++++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 104 ++++++++++++++++ pubspec.yaml | 3 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 9 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 lib/notifications/tasks.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6ac669c..51fdbb0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + @@ -41,5 +42,12 @@ + + + + + + + diff --git a/lib/api/api.dart b/lib/api/api.dart index 39264aa..bfb73c3 100644 --- a/lib/api/api.dart +++ b/lib/api/api.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:crypto/crypto.dart'; +import 'package:shared_preferences/shared_preferences.dart'; part 'feed.dart'; part 'item.dart'; @@ -21,12 +22,16 @@ class FeverAPI { String? apiKey; String? apiUrl; + late final APICache cache; + /// We use a shared HTTP client for all requests final _httpClient = http.Client(); - late final APICache cache; + /// If set, the `newestKnownPost` value will be updated automatically. + /// This is used for notifications. + SharedPreferences? sharedPrefs; - FeverAPI({this.apiKey, this.apiUrl}) { + FeverAPI({this.apiKey, this.apiUrl, this.sharedPrefs}) { cache = APICache(this); } @@ -64,19 +69,20 @@ class FeverAPI { } APIRequestBuilder request() { - return APIRequestBuilder(this, _httpClient); + return APIRequestBuilder(this, _httpClient, sharedPrefs); } } class APIRequestBuilder { final FeverAPI api; + final SharedPreferences? _sharedPrefs; final http.Client _httpClient; List args = []; Item? _markedItem; ItemMarkType? _markedItemAs; - APIRequestBuilder(this.api, this._httpClient); + APIRequestBuilder(this.api, this._httpClient, this._sharedPrefs); void _addArg(String arg) { if (!args.contains(arg)) args.add(arg); @@ -173,12 +179,19 @@ class APIRequestBuilder { } if (data.containsKey('items')) { + var currentHighest = _sharedPrefs?.getInt('newestKnownPost') ?? 0; + List items = (data['items'] as List) .map((json) => Item.fromJSON(api, json)) .toList(); for (var item in items) { + if (item.id > currentHighest) { + currentHighest = item.id; + } + api.cache.items.set(item.id, item); + _sharedPrefs?.setInt('newestKnownPost', currentHighest); } } diff --git a/lib/main.dart b/lib/main.dart index ea60b0e..d24a241 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:feet/notifications/tasks.dart'; import 'package:feet/widgets/centered_page_hint.dart'; import 'package:feet/widgets/filter_menu.dart'; import 'package:feet/widgets/homepage_post.dart'; @@ -15,6 +16,7 @@ import 'api/api.dart'; const FETCH_MAX_POSTS = 1000; void main() { + setupBackgroundTasks(); runApp(MyApp()); } @@ -27,6 +29,7 @@ class MyApp extends StatelessWidget { Future _loadPrefs() async { var prefs = await SharedPreferences.getInstance(); this.prefs = prefs; + api.sharedPrefs = prefs; var apiKey = prefs.getString('apiKey'), apiUrl = prefs.getString('apiUrl'); if (apiUrl != null && apiKey != null) { diff --git a/lib/notifications/tasks.dart b/lib/notifications/tasks.dart new file mode 100644 index 0000000..e948291 --- /dev/null +++ b/lib/notifications/tasks.dart @@ -0,0 +1,116 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:feet/api/api.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:workmanager/workmanager.dart'; + +@pragma('vm:entry-point') +void callbackDispatcher() { + Workmanager().executeTask((taskName, inputData) async { + print("Native called background task: $taskName"); + + if (taskName == 'fetchNotifications') { + final sharedPreferences = await SharedPreferences.getInstance(); + + try { + var initialized = await FlutterLocalNotificationsPlugin().initialize( + const InitializationSettings( + android: AndroidInitializationSettings('@mipmap/ic_launcher'), + ), + ); + + if (initialized == false) { + print("Notification plugin did not initialize; cancelling task"); + return true; + } + + var apiUrl = sharedPreferences.getString('apiUrl'), + apiKey = sharedPreferences.getString('apiKey'), + latest = sharedPreferences.getInt('newestKnownPost'); + + if (apiUrl == null || apiKey == null) { + print("API URL or API key not set; cancelling task"); + return true; + } + + var api = FeverAPI( + apiKey: apiKey, + apiUrl: apiUrl, + sharedPrefs: sharedPreferences, + ); + + await api.request().withItems().sinceId(latest ?? 0).execute(); + var items = + api.cache.items.getAll().values.where((post) => !post.isRead); + + // Only fetch feeds and favicons if necessary to avoid wasting bandwidth + if (items.isNotEmpty) { + print("New posts found, fetching feeds and favicons"); + await api.request().withFeeds().withFavicons().execute(); + } + + for (var item in items) { + var feed = api.cache.feeds.get(item.feedId); + print("Notifying for post: ${item.id}"); + + AndroidBitmap? iconData; + var favicon = api.cache.favicons.get(feed?.faviconId ?? -1); + if (favicon != null) { + try { + iconData = ByteArrayAndroidBitmap.fromBase64String( + favicon.data.split(',')[1]); + } catch (e) { + print("Failed to decode favicon: $e - Continuing"); + } + } + + await FlutterLocalNotificationsPlugin().show( + Random().nextInt(10000000), // Can't use the post ID here because + // FreshRSS's IDs are too large + feed != null ? 'New post - ${feed.title}' : 'New post', + item.title, + NotificationDetails( + android: AndroidNotificationDetails( + 'new_posts', + 'New posts', + autoCancel: true, + channelShowBadge: true, + importance: Importance.low, + largeIcon: iconData, + ), + ), + ); + } + } catch (e) { + print(e); + return false; + } + + return true; + } + + return true; + }); +} + +void setupBackgroundTasks() async { + WidgetsFlutterBinding.ensureInitialized(); + Workmanager().initialize( + callbackDispatcher, + isInDebugMode: kDebugMode, + ); + + await Permission.notification.request(); + + // Runs every 15 minutes + Workmanager().registerPeriodicTask( + 'fetch-notifications', + 'fetchNotifications', + initialDelay: const Duration(seconds: 30), + ); +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index bd4cd05..782e264 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import dynamic_color +import flutter_local_notifications import path_provider_macos import share_plus import shared_preferences_macos @@ -14,6 +15,7 @@ import wakelock_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index d09b2b4..5f98c6d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: "139d809800a412ebb26a3892da228b2d0ba36f0ef5d9a82166e5e52ec8d61611" + url: "https://pub.dev" + source: hosted + version: "2.3.2" async: dependency: transitive description: @@ -89,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + dbus: + dependency: transitive + description: + name: dbus + sha256: "3350efa144252eaa4264055dded4404a94b770cfe914f1d08c20953aee55cac2" + url: "https://pub.dev" + source: hosted + version: "0.5.4" dynamic_color: dependency: "direct main" description: @@ -150,6 +166,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "11d12ac8c713d28a654036c6e61464ef039eb560296282b17b933682ede0b4b8" + url: "https://pub.dev" + source: hosted + version: "9.1.4" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "0824c36855ef1f159e1b957e4ff43dfd171372d6915424bc5004e277db4f1bc5" + url: "https://pub.dev" + source: hosted + version: "0.3.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "21bceee103a66a53b30ea9daf677f990e5b9e89b62f222e60dd241cd08d63d3a" + url: "https://pub.dev" + source: hosted + version: "5.0.0" flutter_math_fork: dependency: transitive description: @@ -344,6 +384,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + pedantic: + dependency: transitive + description: + name: pedantic + sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "33c6a1253d1f95fd06fa74b65b7ba907ae9811f9d5c1d3150e51417d04b8d6a8" + url: "https://pub.dev" + source: hosted + version: "10.2.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "8028362b40c4a45298f1cbfccd227c8dd6caf0e27088a69f2ba2ab15464159e2" + url: "https://pub.dev" + source: hosted + version: "10.2.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: "9c370ef6a18b1c4b2f7f35944d644a56aa23576f23abee654cf73968de93f163" + url: "https://pub.dev" + source: hosted + version: "9.0.7" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: "68abbc472002b5e6dfce47fe9898c6b7d8328d58b5d2524f75e277c07a97eb84" + url: "https://pub.dev" + source: hosted + version: "3.9.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: f67cab14b4328574938ecea2db3475dad7af7ead6afab6338772c5f88963e38b + url: "https://pub.dev" + source: hosted + version: "0.1.2" petitparser: dependency: transitive description: @@ -525,6 +613,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.16" + timezone: + dependency: transitive + description: + name: timezone + sha256: "57b35f6e8ef731f18529695bffc62f92c6189fac2e52c12d478dec1931afb66e" + url: "https://pub.dev" + source: hosted + version: "0.8.0" tuple: dependency: transitive description: @@ -741,6 +837,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + workmanager: + dependency: "direct main" + description: + name: workmanager + sha256: e0be7e35d644643f164ee45d2ce14414f0e0fdde19456aa66065f35a0b1d2ea1 + url: "https://pub.dev" + source: hosted + version: "0.5.1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0aa6373..bd17467 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,9 @@ dependencies: share_plus: ^6.3.0 flutter_html: ^2.2.1 url_launcher: ^6.1.7 + workmanager: ^0.5.1 + flutter_local_notifications: ^9.1.4 + permission_handler: ^10.2.0 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 1a40a84..e16dba3 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,12 +7,15 @@ #include "generated_plugin_registrant.h" #include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { DynamicColorPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("DynamicColorPluginCApi")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 9feb042..0a43910 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dynamic_color + permission_handler_windows share_plus url_launcher_windows )