From 612fc7b0d995871a00d2ee9c234c8c253f2e2198 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Mon, 12 Apr 2021 19:26:05 +0200 Subject: [PATCH] Start to integrate push notifications --- lib/helpers/account_helper.dart | 3 + lib/helpers/preferences_helper.dart | 5 + lib/helpers/push_notifications_helper.dart | 88 +++++++++ lib/helpers/server_config_helper.dart | 5 + lib/models/config.dart | 9 +- lib/models/server_config.dart | 14 ++ lib/ui/routes/push_notifications_route.dart | 188 ++++++++++++++++++++ lib/ui/widgets/init_widget.dart | 10 +- lib/utils/flutter_utils.dart | 4 + 9 files changed, 324 insertions(+), 2 deletions(-) create mode 100644 lib/helpers/push_notifications_helper.dart create mode 100644 lib/ui/routes/push_notifications_route.dart diff --git a/lib/helpers/account_helper.dart b/lib/helpers/account_helper.dart index 10838f9..073cef7 100644 --- a/lib/helpers/account_helper.dart +++ b/lib/helpers/account_helper.dart @@ -1,5 +1,6 @@ import 'package:comunic/helpers/api_helper.dart'; import 'package:comunic/helpers/preferences_helper.dart'; +import 'package:comunic/helpers/push_notifications_helper.dart'; import 'package:comunic/helpers/websocket_helper.dart'; import 'package:comunic/models/api_request.dart'; import 'package:comunic/models/authentication_details.dart'; @@ -77,6 +78,8 @@ class AccountHelper { /// Sign out user Future signOut() async { + await PushNotificationsHelper.clearLocalStatus(); + await APIRequest.withLogin("account/logout").exec(); final preferencesHelper = await PreferencesHelper.getInstance(); diff --git a/lib/helpers/preferences_helper.dart b/lib/helpers/preferences_helper.dart index d9a8648..7230891 100644 --- a/lib/helpers/preferences_helper.dart +++ b/lib/helpers/preferences_helper.dart @@ -13,6 +13,7 @@ enum PreferencesKeyList { ENABLE_DARK_THEME, FORCE_MOBILE_MODE, SHOW_PERFORMANCE_OVERLAY, + PUSH_NOTIFICATIONS_STATUS, } const _PreferenceKeysName = { @@ -62,6 +63,10 @@ class PreferencesHelper { } } + bool containsKey(PreferencesKeyList key) { + return _sharedPreferences.containsKey(_PreferenceKeysName[key]); + } + Future removeKey(PreferencesKeyList key) async { return await _sharedPreferences.remove(_PreferenceKeysName[key]); } diff --git a/lib/helpers/push_notifications_helper.dart b/lib/helpers/push_notifications_helper.dart new file mode 100644 index 0000000..6086bea --- /dev/null +++ b/lib/helpers/push_notifications_helper.dart @@ -0,0 +1,88 @@ +import 'package:comunic/helpers/preferences_helper.dart'; +import 'package:comunic/models/api_request.dart'; + +/// Push notifications helper +/// +/// @author Pierre Hubert + +enum PushNotificationsStatus { UNDEFINED, DISABLED, FIREBASE, INDEPENDENT } + +const _PushNotificationsAPIMap = { + "undefined": PushNotificationsStatus.UNDEFINED, + "disabled": PushNotificationsStatus.DISABLED, + "firebase": PushNotificationsStatus.FIREBASE, + "independent": PushNotificationsStatus.INDEPENDENT +}; + +class PushNotificationsHelper { + /// Get cached status of push notifications + static Future getLocalStatus() async { + final pref = await PreferencesHelper.getInstance(); + + if (!pref.containsKey(PreferencesKeyList.PUSH_NOTIFICATIONS_STATUS)) + return PushNotificationsStatus.UNDEFINED; + + return _PushNotificationsAPIMap[ + pref.getString(PreferencesKeyList.PUSH_NOTIFICATIONS_STATUS)]; + } + + /// Refresh local status with information from server + /// + /// Throws in case of failure + static Future refreshLocalStatus() async { + final pref = await PreferencesHelper.getInstance(); + + final response = await APIRequest.withLogin("push_notifications/status") + .execWithThrowGetObject(); + + switch (response["status"]) { + case "undefined": + await pref.removeKey(PreferencesKeyList.PUSH_NOTIFICATIONS_STATUS); + break; + + case "disabled": + await pref.setString( + PreferencesKeyList.PUSH_NOTIFICATIONS_STATUS, "disabled"); + break; + + case "firebase": + await pref.setString( + PreferencesKeyList.PUSH_NOTIFICATIONS_STATUS, "firebase"); + break; + + case "independent": + await pref.setString( + PreferencesKeyList.PUSH_NOTIFICATIONS_STATUS, "independent"); + // TODO : Invoke plugin to apply new WebSocket URL + break; + + default: + print( + "Push notifications status ${response["status"]} is unknown, defaulting to disabled!"); + await pref.setString( + PreferencesKeyList.PUSH_NOTIFICATIONS_STATUS, "disabled"); + } + } + + /// Clear local push notifications status + static Future clearLocalStatus() async { + await (await PreferencesHelper.getInstance()) + .removeKey(PreferencesKeyList.PUSH_NOTIFICATIONS_STATUS); + + // TODO : stop local refresh notification refresh + } + + /// Set new push notification status on the server + static Future setNewStatus( + PushNotificationsStatus newStatus, { + String firebaseToken = "", + }) async => + await APIRequest.withLogin("push_notifications/configure") + .addString( + "status", + _PushNotificationsAPIMap.entries + .firstWhere((e) => e.value == newStatus) + .key) + .addString("firebase_token", firebaseToken) + .execWithThrow(); +} diff --git a/lib/helpers/server_config_helper.dart b/lib/helpers/server_config_helper.dart index 4f7a994..4b1118f 100644 --- a/lib/helpers/server_config_helper.dart +++ b/lib/helpers/server_config_helper.dart @@ -17,6 +17,7 @@ class ServerConfigurationHelper { (await APIRequest.withoutLogin("server/config").execWithThrow()) .getObject(); + final pushNotificationsPolicy = response["push_notifications"]; final passwordPolicy = response["password_policy"]; final dataConservationPolicy = response["data_conservation_policy"]; final conversationsPolicy = response["conversations_policy"]; @@ -27,6 +28,10 @@ class ServerConfigurationHelper { termsURL: response["terms_url"], playStoreURL: response["play_store_url"], androidDirectDownloadURL: response["android_direct_download_url"], + notificationsPolicy: NotificationsPolicy( + hasFirebase: pushNotificationsPolicy["has_firebase"], + hasIndependent: pushNotificationsPolicy["has_independent"], + ), passwordPolicy: PasswordPolicy( allowMailInPassword: passwordPolicy["allow_email_in_password"], allowNameInPassword: passwordPolicy["allow_name_in_password"], diff --git a/lib/models/config.dart b/lib/models/config.dart index 315e94f..5abcd97 100644 --- a/lib/models/config.dart +++ b/lib/models/config.dart @@ -1,25 +1,32 @@ +import 'dart:ui'; + import 'package:meta/meta.dart'; /// Application configuration model /// /// @author Pierre HUBERT +const defaultColor = Color(0xFF1A237E); + /// Configuration class class Config { final String apiServerName; final String apiServerUri; final bool apiServerSecure; final String clientName; + final Color splashBackgroundColor; const Config({ @required this.apiServerName, @required this.apiServerUri, @required this.apiServerSecure, @required this.clientName, + this.splashBackgroundColor = defaultColor, }) : assert(apiServerName != null), assert(apiServerUri != null), assert(apiServerSecure != null), - assert(clientName != null); + assert(clientName != null), + assert(splashBackgroundColor != null); /// Get and set static configuration static Config _config; diff --git a/lib/models/server_config.dart b/lib/models/server_config.dart index b19879b..1300796 100644 --- a/lib/models/server_config.dart +++ b/lib/models/server_config.dart @@ -5,6 +5,17 @@ import 'package:version/version.dart'; /// /// @author Pierre Hubert +class NotificationsPolicy { + final bool hasFirebase; + final bool hasIndependent; + + const NotificationsPolicy({ + @required this.hasFirebase, + @required this.hasIndependent, + }) : assert(hasFirebase != null), + assert(hasIndependent != null); +} + class PasswordPolicy { final bool allowMailInPassword; final bool allowNameInPassword; @@ -106,6 +117,7 @@ class ServerConfig { final String termsURL; final String playStoreURL; final String androidDirectDownloadURL; + final NotificationsPolicy notificationsPolicy; final PasswordPolicy passwordPolicy; final ServerDataConservationPolicy dataConservationPolicy; final ConversationsPolicy conversationsPolicy; @@ -115,6 +127,7 @@ class ServerConfig { @required this.termsURL, @required this.playStoreURL, @required this.androidDirectDownloadURL, + @required this.notificationsPolicy, @required this.passwordPolicy, @required this.dataConservationPolicy, @required this.conversationsPolicy, @@ -122,6 +135,7 @@ class ServerConfig { assert(termsURL != null), assert(playStoreURL != null), assert(androidDirectDownloadURL != null), + assert(notificationsPolicy != null), assert(passwordPolicy != null), assert(dataConservationPolicy != null), assert(conversationsPolicy != null); diff --git a/lib/ui/routes/push_notifications_route.dart b/lib/ui/routes/push_notifications_route.dart new file mode 100644 index 0000000..6a4686f --- /dev/null +++ b/lib/ui/routes/push_notifications_route.dart @@ -0,0 +1,188 @@ +import 'package:comunic/helpers/push_notifications_helper.dart'; +import 'package:comunic/helpers/server_config_helper.dart'; +import 'package:comunic/models/config.dart'; +import 'package:comunic/ui/widgets/async_screen_widget.dart'; +import 'package:comunic/utils/flutter_utils.dart'; +import 'package:comunic/utils/intl_utils.dart'; +import 'package:comunic/utils/log_utils.dart'; +import 'package:comunic/utils/ui_utils.dart'; +import 'package:flutter/material.dart'; + +/// Push notifications configuration route +/// +/// @author Pierre Hubert + +Future showInitialPushNotificationsConfiguration( + BuildContext context) async { + // Check if no notifications service are available + if (!srvConfig.notificationsPolicy.hasIndependent && + (!isAndroid || !srvConfig.notificationsPolicy.hasFirebase)) return; + + await Navigator.of(context).push( + MaterialPageRoute(builder: (c) => PushNotificationsConfigurationRoute())); +} + +class PushNotificationsConfigurationRoute extends StatefulWidget { + @override + _PushNotificationsConfigurationRouteState createState() => + _PushNotificationsConfigurationRouteState(); +} + +class _PushNotificationsConfigurationRouteState + extends State { + PushNotificationsStatus currStatus; + + bool get canSubmit => + (currStatus ?? PushNotificationsStatus.UNDEFINED) != + PushNotificationsStatus.UNDEFINED; + + Future _refresh() async { + await PushNotificationsHelper.refreshLocalStatus(); + currStatus = await PushNotificationsHelper.getLocalStatus(); + } + + @override + Widget build(BuildContext context) { + return Material( + color: config().splashBackgroundColor, + child: Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: AsyncScreenWidget( + onReload: _refresh, + onBuild: _buildForm, + errorMessage: tr("Failed to load push notifications settings!"), + ), + ), + )); + } + + Widget _buildForm() => ConstrainedBox( + constraints: BoxConstraints(maxWidth: 300), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Spacer(), + Icon(Icons.notifications_none), + Spacer(), + Text( + tr("Comunic can now send you notifications to your device when the application is closed if you want."), + textAlign: TextAlign.center, + ), + Spacer(), + _NotificationOption( + title: tr("Use Google services"), + description: tr( + "Use the Google Play services to send you notifications. This option is less privacy-friendly, but it will work on most phones and will save your battery life."), + option: PushNotificationsStatus.FIREBASE, + current: currStatus, + available: srvConfig.notificationsPolicy.hasFirebase, + onChanged: (s) => setState(() => currStatus = s), + ), + _NotificationOption( + title: tr("Use independent notifications service"), + description: tr( + "Configure Comunic to use your own self-hosted notifications service. This option is much more privacy-friendly, but it will drain your battery life."), + option: PushNotificationsStatus.INDEPENDENT, + current: currStatus, + available: + srvConfig.notificationsPolicy.hasIndependent && isAndroid, + onChanged: (s) => setState(() => currStatus = s), + ), + _NotificationOption( + title: tr("Do not send notification"), + description: tr( + "Disable notifications services. You will always be able to change it from application settings."), + option: PushNotificationsStatus.DISABLED, + current: currStatus, + available: true, + onChanged: (s) => setState(() => currStatus = s), + ), + Spacer(), + OutlinedButton( + onPressed: canSubmit ? _submit : null, + child: Text(tr("Configure").toUpperCase()), + style: OutlinedButton.styleFrom(primary: Colors.white)), + Spacer(), + ], + ), + ); + + Future _submit() async { + try { + switch (currStatus) { + case PushNotificationsStatus.DISABLED: + break; + + case PushNotificationsStatus.FIREBASE: + // TODO: Handle this case. + throw new Exception("Firebase not supported yet!"); + break; + case PushNotificationsStatus.INDEPENDENT: + // TODO: Handle this case. + throw new Exception("Independent not supported yet!"); + break; + + default: + throw new Exception("Unknown status!"); + } + + await PushNotificationsHelper.clearLocalStatus(); + await PushNotificationsHelper.setNewStatus(currStatus); + await PushNotificationsHelper.refreshLocalStatus(); + + Navigator.of(context).pop(); + } catch (e, s) { + logError(e, s); + showAlert( + context: context, + message: tr("Failed to configure push notifications!")); + } + } +} + +class _NotificationOption extends StatelessWidget { + final String title; + final String description; + final PushNotificationsStatus option; + final PushNotificationsStatus current; + final bool available; + final Function(PushNotificationsStatus) onChanged; + + const _NotificationOption({ + Key key, + @required this.title, + @required this.description, + @required this.option, + @required this.current, + @required this.available, + @required this.onChanged, + }) : assert(title != null), + assert(description != null), + assert(option != null), + assert(current != null), + assert(available != null), + assert(onChanged != null), + super(key: key); + + @override + Widget build(BuildContext context) => !available + ? Container() + : Theme( + data: Theme.of(context).copyWith( + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + ), + child: ListTile( + leading: Radio( + value: option, + groupValue: current, + onChanged: onChanged, + ), + title: Text(title), + subtitle: Text(description), + contentPadding: EdgeInsets.all(0), + onTap: () => onChanged(option), + ), + ); +} diff --git a/lib/ui/widgets/init_widget.dart b/lib/ui/widgets/init_widget.dart index 928fc48..8747738 100644 --- a/lib/ui/widgets/init_widget.dart +++ b/lib/ui/widgets/init_widget.dart @@ -1,13 +1,16 @@ import 'package:comunic/helpers/account_helper.dart'; import 'package:comunic/helpers/events_helper.dart'; +import 'package:comunic/helpers/push_notifications_helper.dart'; import 'package:comunic/helpers/server_config_helper.dart'; import 'package:comunic/helpers/version_helper.dart'; import 'package:comunic/helpers/websocket_helper.dart'; +import 'package:comunic/models/config.dart'; import 'package:comunic/ui/dialogs/deprecation_dialog.dart'; import 'package:comunic/ui/routes/login_route.dart'; import 'package:comunic/ui/routes/main_route/main_route.dart'; import 'package:comunic/ui/routes/main_route/smartphone_route.dart'; import 'package:comunic/ui/routes/main_route/tablet_route.dart'; +import 'package:comunic/ui/routes/push_notifications_route.dart'; import 'package:comunic/ui/widgets/safe_state.dart'; import 'package:comunic/utils/flutter_utils.dart'; import 'package:comunic/utils/intl_utils.dart'; @@ -74,6 +77,11 @@ class _InitializeWidgetState extends SafeState { return; } + print("Check push notifications configuration..."); + if (await PushNotificationsHelper.getLocalStatus() == + PushNotificationsStatus.UNDEFINED) + await showInitialPushNotificationsConfiguration(context); + print("Attempting WebSocket connection..."); setState(() { @@ -107,7 +115,7 @@ class _InitializeWidgetState extends SafeState { /// Build loading widget Widget _buildNonReadyWidget() { return Scaffold( - backgroundColor: Colors.indigo.shade900, + backgroundColor: config().splashBackgroundColor, body: Center( child: Material( color: Colors.transparent, diff --git a/lib/utils/flutter_utils.dart b/lib/utils/flutter_utils.dart index f047919..f170468 100644 --- a/lib/utils/flutter_utils.dart +++ b/lib/utils/flutter_utils.dart @@ -1,6 +1,10 @@ +import 'dart:io'; + /// Flutter utilities /// /// @author Pierre Hubert import 'package:flutter/foundation.dart' show kIsWeb; bool get isWeb => kIsWeb; + +bool get isAndroid => !isWeb && Platform.isAndroid; \ No newline at end of file