diff --git a/android/app/build.gradle b/android/app/build.gradle index 8aa8b755..f2d388bc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -43,7 +43,7 @@ android { } defaultConfig { - applicationId "chat.fluffy.fluffychat" + applicationId "chat.fluffy.fluffychat.dev" minSdkVersion 21 targetSdkVersion 30 versionCode flutterVersionCode.toInteger() @@ -85,8 +85,9 @@ dependencies { androidTestImplementation 'androidx.test:runner:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' implementation "net.zetetic:android-database-sqlcipher:4.4.0" // needed for moor_ffi w/ sqlcipher + implementation 'com.github.UnifiedPush:android-connector:1.0.0-beta3' // needed for unifiedpush } if(file("google-services.json").exists()){ - apply plugin: 'com.google.gms.google-services' + // apply plugin: 'com.google.gms.google-services' } diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index fc480dc2..6d02cd93 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,5 +1,5 @@ + package="chat.fluffy.fluffychat.dev"> diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a26dd12c..77d400f0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ + package="chat.fluffy.fluffychat.dev"> + package="chat.fluffy.fluffychat.dev"> diff --git a/android/fastlane/Appfile b/android/fastlane/Appfile index 872e0785..548016c0 100644 --- a/android/fastlane/Appfile +++ b/android/fastlane/Appfile @@ -1,2 +1,2 @@ json_key_file("keys.json") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one -package_name("chat.fluffy.fluffychat") # e.g. com.krausefx.app +package_name("chat.fluffy.fluffychat.dev") # e.g. com.krausefx.app diff --git a/lib/components/matrix.dart b/lib/components/matrix.dart index f16c9790..d5bbd936 100644 --- a/lib/components/matrix.dart +++ b/lib/components/matrix.dart @@ -6,7 +6,6 @@ import 'package:adaptive_dialog/adaptive_dialog.dart'; import 'package:adaptive_page_layout/adaptive_page_layout.dart'; import 'package:famedlysdk/encryption.dart'; import 'package:famedlysdk/famedlysdk.dart'; -import 'package:fluffychat/utils/firebase_controller.dart'; import 'package:fluffychat/utils/matrix_locals.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/sentry_controller.dart'; @@ -33,6 +32,10 @@ import 'dialogs/key_verification_dialog.dart'; import '../utils/platform_infos.dart'; import '../app_config.dart'; import '../config/setting_keys.dart'; +import '../utils/famedlysdk_client.dart'; +import '../utils/plugins/background_push_plugin.dart'; +import '../utils/plugins/background_sync_plugin.dart'; +import '../utils/plugins/local_notification_plugin.dart'; class Matrix extends StatefulWidget { static const String callNamespace = 'chat.fluffy.jitsi_call'; @@ -64,6 +67,24 @@ class MatrixState extends State { @override BuildContext get context => widget.context; + BackgroundPushPlugin _backgroundPushPlugin; + // ignore: unused_field + BackgroundSyncPlugin _backgroundSyncPlugin; + LocalNotificationPlugin _localNotificationsPlugin; + + L10n _l10n; + L10n get l10n { + if (_l10n != null) { + return _l10n; + } + final l10n = L10n.of(context); + if (l10n != null) { + _l10n = l10n; + return _l10n; + } + return null; + } + Map get shareContent => _shareContent; set shareContent(Map content) { _shareContent = content; @@ -77,13 +98,6 @@ class MatrixState extends State { String activeRoomId; File wallpaper; - String clientName; - - void clean() async { - if (!kIsWeb) return; - - await store.deleteItem(clientName); - } void _initWithStore() async { try { @@ -98,15 +112,15 @@ class MatrixState extends State { await client.requestThirdPartyIdentifiers().then((l) { if (l.isEmpty) { Flushbar( - title: L10n.of(context).warning, - message: L10n.of(context).noPasswordRecoveryDescription, + title: l10n.warning, + message: l10n.noPasswordRecoveryDescription, mainButton: RaisedButton( elevation: 7, color: Theme.of(context).scaffoldBackgroundColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(6), ), - child: Text(L10n.of(context).edit), + child: Text(l10n.edit), onPressed: () => AdaptivePageLayout.of(context).pushNamed('/settings/3pid'), ), @@ -139,7 +153,7 @@ class MatrixState extends State { case AuthenticationTypes.password: final input = await showTextInputDialog( context: context, - title: L10n.of(context).pleaseEnterYourPassword, + title: l10n.pleaseEnterYourPassword, textFields: [ DialogTextField( minLines: 1, @@ -165,10 +179,10 @@ class MatrixState extends State { ); if (OkCancelResult.ok == await showOkCancelAlertDialog( - message: L10n.of(context).pleaseFollowInstructionsOnWeb, + message: l10n.pleaseFollowInstructionsOnWeb, context: context, - okLabel: L10n.of(context).next, - cancelLabel: L10n.of(context).cancel, + okLabel: l10n.next, + cancelLabel: l10n.cancel, )) { return uiaRequest.completeStage( AuthenticationData(session: uiaRequest.session), @@ -186,7 +200,7 @@ class MatrixState extends State { if (room.notificationCount == 0) return; final event = Event.fromJson(eventUpdate.content, room); final body = event.getLocalizedBody( - MatrixLocals(L10n.of(context)), + MatrixLocals(l10n), withSenderNamePrefix: !room.isDirectChat || room.lastEvent.senderId == client.userID, ); @@ -200,7 +214,7 @@ class MatrixState extends State { ..autoplay = true ..load(); html.Notification( - room.getLocalizedDisplayname(MatrixLocals(L10n.of(context))), + room.getLocalizedDisplayname(MatrixLocals(l10n)), body: body, icon: icon, ); @@ -208,7 +222,7 @@ class MatrixState extends State { /*var sessionBus = DBusClient.session(); var client = NotificationClient(sessionBus); _linuxNotificationIds[roomId] = await client.notify( - room.getLocalizedDisplayname(MatrixLocals(L10n.of(context))), + room.getLocalizedDisplayname(MatrixLocals(l10n)), body: body, replacesID: _linuxNotificationIds[roomId] ?? -1, appName: AppConfig.applicationName, @@ -261,30 +275,9 @@ class MatrixState extends State { }); }); } - clientName = - '${AppConfig.applicationName} ${kIsWeb ? 'Web' : Platform.operatingSystem}'; - final Set verificationMethods = { - KeyVerificationMethod.numbers - }; - if (PlatformInfos.isMobile || (!kIsWeb && Platform.isLinux)) { - // emojis don't show in web somehow - verificationMethods.add(KeyVerificationMethod.emoji); - } - client = Client( - clientName, - enableE2eeRecovery: true, - verificationMethods: verificationMethods, - importantStateEvents: { - 'im.ponies.room_emotes', // we want emotes to work properly - }, - databaseBuilder: getDatabase, - supportedLoginTypes: { - AuthenticationTypes.password, - if (PlatformInfos.isMobile) AuthenticationTypes.sso - }, - ); - LoadingDialog.defaultTitle = L10n.of(context).loadingPleaseWait; - LoadingDialog.defaultBackLabel = L10n.of(context).close; + client = getClient(); + LoadingDialog.defaultTitle = l10n.loadingPleaseWait; + LoadingDialog.defaultBackLabel = l10n.close; LoadingDialog.defaultOnError = (Object e) => e.toLocalizedString(context); onRoomKeyRequestSub ??= @@ -296,11 +289,11 @@ class MatrixState extends State { final sender = room.getUserByMXIDSync(request.sender); if (await showOkCancelAlertDialog( context: context, - title: L10n.of(context).requestToReadOlderMessages, + title: l10n.requestToReadOlderMessages, message: - '${sender.id}\n\n${L10n.of(context).device}:\n${request.requestingDevice.deviceId}\n\n${L10n.of(context).publicKey}:\n${request.requestingDevice.ed25519Key.beautified}', - okLabel: L10n.of(context).verify, - cancelLabel: L10n.of(context).deny, + '${sender.id}\n\n${l10n.device}:\n${request.requestingDevice.deviceId}\n\n${l10n.publicKey}:\n${request.requestingDevice.ed25519Key.beautified}', + okLabel: l10n.verify, + cancelLabel: l10n.deny, ) == OkCancelResult.ok) { await request.forwardKey(); @@ -319,8 +312,8 @@ class MatrixState extends State { }; if (await showOkCancelAlertDialog( context: context, - title: L10n.of(context).newVerificationRequest, - message: L10n.of(context).askVerificationRequest(request.userId), + title: l10n.newVerificationRequest, + message: l10n.askVerificationRequest(request.userId), ) == OkCancelResult.ok) { request.onUpdate = null; @@ -328,7 +321,7 @@ class MatrixState extends State { await request.acceptVerification(); await KeyVerificationDialog( request: request, - l10n: L10n.of(context), + l10n: l10n, ).show(context); } else { request.onUpdate = null; @@ -346,12 +339,6 @@ class MatrixState extends State { if (loginState != state) { loginState = state; widget.apl.currentState.pushNamedAndRemoveAllOthers('/'); - if (loginState == LoginState.logged) { - FirebaseController.context = context; - FirebaseController.matrix = this; - FirebaseController.setupFirebase(clientName) - .catchError(SentryController.captureException); - } } }); onUiaRequest ??= client.onUiaRequest.stream.listen(_onUiaRequest); @@ -367,6 +354,16 @@ class MatrixState extends State { .listen(_showLocalNotification); }); } + + if (PlatformInfos.isMobile) { + _backgroundPushPlugin = BackgroundPushPlugin(this); + // + _backgroundPushPlugin?.setupPush(); + } + if (PlatformInfos.isMobile || PlatformInfos.isWeb) { + _localNotificationsPlugin = LocalNotificationPlugin(this); + } + _backgroundSyncPlugin = BackgroundSyncPlugin(this); } void initSettings() { @@ -402,6 +399,11 @@ class MatrixState extends State { onNotification?.cancel(); onFocusSub?.cancel(); onBlurSub?.cancel(); + _localNotificationsPlugin.onFocusSub?.cancel(); + _localNotificationsPlugin.onBlurSub?.cancel(); + _localNotificationsPlugin?.matrix = null; + _backgroundPushPlugin?.onLogin?.cancel(); + super.dispose(); } diff --git a/lib/config/setting_keys.dart b/lib/config/setting_keys.dart index ca067f0c..5e1a7018 100644 --- a/lib/config/setting_keys.dart +++ b/lib/config/setting_keys.dart @@ -15,4 +15,5 @@ abstract class SettingKeys { static const String unifiedPushRegistered = 'chat.fluffy.unifiedpush.registered'; static const String unifiedPushEndpoint = 'chat.fluffy.unifiedpush.endpoint'; + static const String notificationCurrentIds = 'chat.fluffy.notification_ids'; } diff --git a/lib/main.dart b/lib/main.dart index 9e071022..40a15f2a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,12 +19,26 @@ import 'package:universal_html/prefer_universal/html.dart' as html; import 'components/matrix.dart'; import 'config/themes.dart'; import 'app_config.dart'; +import 'utils/famedlysdk_client.dart'; +import 'utils/platform_infos.dart'; +import 'utils/plugins/background_push_plugin.dart'; +import 'utils/plugins/background_sync_plugin.dart'; +import 'utils/plugins/local_notification_plugin.dart'; void main() async { + WidgetsFlutterBinding.ensureInitialized(); SystemChrome.setSystemUIOverlayStyle( SystemUiOverlayStyle(statusBarColor: Colors.transparent)); FlutterError.onError = (FlutterErrorDetails details) => Zone.current.handleUncaughtError(details.exception, details.stack); + + final client = getClient(); + BackgroundSyncPlugin.clientOnly(client); + if (PlatformInfos.isMobile) { + LocalNotificationPlugin.clientOnly(client); + BackgroundPushPlugin.clientOnly(client); + } + runZonedGuarded( () => runApp(PlatformInfos.isMobile ? AppLock( diff --git a/lib/utils/famedlysdk_client.dart b/lib/utils/famedlysdk_client.dart new file mode 100644 index 00000000..30fac4d9 --- /dev/null +++ b/lib/utils/famedlysdk_client.dart @@ -0,0 +1,34 @@ +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:famedlysdk/encryption.dart'; +import 'platform_infos.dart'; +import 'famedlysdk_store.dart'; + +Client _client; + +Client getClient() { + if (_client != null) { + return _client; + } + final clientName = PlatformInfos.clientName; + final Set verificationMethods = { + KeyVerificationMethod.numbers + }; + if (PlatformInfos.isMobile || PlatformInfos.isLinux) { + // emojis don't show in web somehow + verificationMethods.add(KeyVerificationMethod.emoji); + } + _client = Client( + clientName, + enableE2eeRecovery: true, + verificationMethods: verificationMethods, + importantStateEvents: { + 'im.ponies.room_emotes', // we want emotes to work properly + }, + databaseBuilder: getDatabase, + supportedLoginTypes: { + AuthenticationTypes.password, + if (PlatformInfos.isMobile) AuthenticationTypes.sso + }, + ); + return _client; +} diff --git a/lib/utils/firebase_controller.dart b/lib/utils/firebase_controller.dart deleted file mode 100644 index 2ec1faf1..00000000 --- a/lib/utils/firebase_controller.dart +++ /dev/null @@ -1,528 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:fluffychat/app_config.dart'; -import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:flushbar/flushbar_helper.dart'; -import 'package:famedlysdk/famedlysdk.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:fluffychat/components/matrix.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:flutter_gen/gen_l10n/l10n_en.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:unifiedpush/unifiedpush.dart'; -import 'package:http/http.dart' as http; - -import '../components/matrix.dart'; -import '../config/setting_keys.dart'; -import 'famedlysdk_store.dart'; -import 'matrix_locals.dart'; - -abstract class FirebaseController { - static final FirebaseMessaging _firebaseMessaging = FirebaseMessaging(); - static final FlutterLocalNotificationsPlugin - _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - static BuildContext context; - static MatrixState matrix; - - static Future setupFirebase(String clientName) async { - if (!PlatformInfos.isMobile) return; - if (Platform.isIOS) iOS_Permission(); - - Function goToRoom = (dynamic message) async { - try { - String roomId; - if (message is String && message[0] == '{') { - message = json.decode(message); - } - if (message is String) { - roomId = message; - } else if (message is Map) { - roomId = (message['data'] ?? - message['notification'] ?? - message)['room_id']; - } - if (roomId?.isEmpty ?? true) throw ('Bad roomId'); - await matrix.widget.apl.currentState - .pushNamedAndRemoveUntilIsFirst('/rooms/${roomId}'); - } catch (_) { - await FlushbarHelper.createError(message: 'Failed to open chat...') - .show(context); - rethrow; - } - }; - - // initialise the plugin. app_icon needs to be a added as a drawable resource to the Android head project - var initializationSettingsAndroid = - AndroidInitializationSettings('notifications_icon'); - var initializationSettingsIOS = - IOSInitializationSettings(onDidReceiveLocalNotification: (i, a, b, c) { - return null; - }); - var initializationSettings = InitializationSettings( - android: initializationSettingsAndroid, - iOS: initializationSettingsIOS, - ); - await _flutterLocalNotificationsPlugin.initialize(initializationSettings, - onSelectNotification: goToRoom); - - // ignore: unawaited_futures - _flutterLocalNotificationsPlugin - .getNotificationAppLaunchDetails() - .then((details) { - if (details == null || !details.didNotificationLaunchApp) { - return; - } - goToRoom(details.payload); - }); - - if (!Platform.isIOS && (await UnifiedPush.getDistributors()).isNotEmpty) { - await setupUnifiedPush(clientName); - return; - } - - String fcmToken; - try { - fcmToken = await _firebaseMessaging.getToken(); - if (fcmToken?.isEmpty ?? true) { - throw '_firebaseMessaging.getToken() has not thrown an exception but returned no token'; - } - } catch (e, s) { - Logs().w('Unable to get firebase token', e, s); - fcmToken = null; - } - if (fcmToken?.isEmpty ?? true) { - // no google services warning - if (await matrix.store.getItemBool(SettingKeys.showNoGoogle, true)) { - await FlushbarHelper.createError( - message: L10n.of(context).noGoogleServicesWarning, - duration: Duration(seconds: 15), - ).show(context); - if (null == await matrix.store.getItemBool(SettingKeys.showNoGoogle)) { - await matrix.store.setItemBool(SettingKeys.showNoGoogle, false); - } - } - return; - } - await setupPusher( - clientName: clientName, - gatewayUrl: AppConfig.pushNotificationsGatewayUrl, - token: fcmToken, - ); - - _firebaseMessaging.configure( - onMessage: _onFcmMessage, - onBackgroundMessage: _onFcmMessage, - onResume: goToRoom, - onLaunch: goToRoom, - ); - Logs().i('[Push] Firebase initialized'); - } - - static Future setupPusher({ - String clientName, - String gatewayUrl, - String token, - Set oldTokens, - }) async { - oldTokens ??= {}; - final client = matrix.client; - final pushers = await client.requestPushers().catchError((e) { - Logs().w('[Push] Unable to request pushers', e); - return []; - }); - var setNewPusher = false; - if (gatewayUrl != null && token != null && clientName != null) { - final currentPushers = pushers.where((pusher) => pusher.pushkey == token); - if (currentPushers.length == 1 && - currentPushers.first.kind == 'http' && - currentPushers.first.appId == AppConfig.pushNotificationsAppId && - currentPushers.first.appDisplayName == clientName && - currentPushers.first.deviceDisplayName == client.deviceName && - currentPushers.first.lang == 'en' && - currentPushers.first.data.url.toString() == gatewayUrl && - currentPushers.first.data.format == - AppConfig.pushNotificationsPusherFormat) { - Logs().i('[Push] Pusher already set'); - } else { - oldTokens.add(token); - setNewPusher = true; - } - } - for (final pusher in pushers) { - if (oldTokens.contains(pusher.pushkey)) { - pusher.kind = null; - try { - await client.setPusher( - pusher, - append: true, - ); - Logs().i('[Push] Removed legacy pusher for this device'); - } catch (err) { - Logs().w('[Push] Failed to remove old pusher', err); - } - } - } - if (setNewPusher) { - try { - await client.setPusher( - Pusher( - token, - AppConfig.pushNotificationsAppId, - clientName, - client.deviceName, - 'en', - PusherData( - url: Uri.parse(gatewayUrl), - format: AppConfig.pushNotificationsPusherFormat, - ), - kind: 'http', - ), - append: false, - ); - } catch (e, s) { - Logs().e('[Push] Unable to set pushers', e, s); - } - } - } - - static Future onUnifiedPushMessage(String payload) async { - Map data; - try { - data = Map.from(json.decode(payload)['notification']); - await _onMessage(data); - } catch (e, s) { - Logs().e('[Push] Failed to display message', e, s); - await _showDefaultNotification(data); - } - } - - static Future onRemoveEndpoint() async { - Logs().i('[Push] Removing UnifiedPush endpoint...'); - // we need our own store object as it is likely that we don't have a context here - final store = Store(); - final oldEndpoint = await store.getItem(SettingKeys.unifiedPushEndpoint); - await store.setItemBool(SettingKeys.unifiedPushRegistered, false); - await store.deleteItem(SettingKeys.unifiedPushEndpoint); - if (matrix != null && (oldEndpoint?.isNotEmpty ?? false)) { - // remove the old pusher - await setupPusher( - oldTokens: {oldEndpoint}, - ); - } - } - - static Future onBackgroundNewEndpoint(String endpoint) async { - // just remove the old endpoint. we'll deal with this when the app next starts up - await onRemoveEndpoint(); - } - - static Future setupUnifiedPush(String clientName) async { - final onNewEndpoint = (String newEndpoint) async { - if (newEndpoint?.isEmpty ?? true) { - await onRemoveEndpoint(); - return; - } - var endpoint = - 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify'; - try { - final url = Uri.parse(newEndpoint) - .replace( - path: '/_matrix/push/v1/notify', - query: '', - ) - .toString() - .split('?') - .first; - final res = json.decode(utf8.decode((await http.get(url)).bodyBytes)); - if (res['gateway'] == 'matrix') { - endpoint = url; - } - } catch (e) { - Logs().i('[Push] No self-hosted unified push gateway present: ' + - newEndpoint); - } - Logs().i('[Push] UnifiedPush using endpoint ' + endpoint); - final oldTokens = {}; - try { - final fcmToken = await _firebaseMessaging.getToken(); - oldTokens.add(fcmToken); - } catch (_) {} - await setupPusher( - clientName: clientName, - gatewayUrl: endpoint, - token: newEndpoint, - oldTokens: oldTokens, - ); - await matrix.store.setItem(SettingKeys.unifiedPushEndpoint, newEndpoint); - await matrix.store.setItemBool(SettingKeys.unifiedPushRegistered, true); - }; - await UnifiedPush.initialize( - onNewEndpoint, // new endpoint - onRemoveEndpoint, // registration failed - onRemoveEndpoint, // registration removed - onRemoveEndpoint, // unregistered - onUnifiedPushMessage, // foreground message - onBackgroundNewEndpoint, // background new endpoint (be static) - onRemoveEndpoint, // background unregistered (be static) - onUnifiedPushMessage, // background push message (be static) - ); - if (!(await matrix.store - .getItemBool(SettingKeys.unifiedPushRegistered, false))) { - Logs().i('[Push] UnifiedPush not registered, attempting to do so...'); - await UnifiedPush.registerAppWithDialog(); - } else { - // make sure the endpoint is up-to-date etc. - await onNewEndpoint( - await matrix.store.getItem(SettingKeys.unifiedPushEndpoint)); - } - } - - static Future _onFcmMessage(Map message) async { - Map data; - try { - data = Map.from(message['data'] ?? message); - await _onMessage(data); - } catch (e, s) { - Logs().e('[Push] Error while processing notification', e, s); - await _showDefaultNotification(data); - } - return null; - } - - static Future _onMessage(Map data) async { - final String roomId = data['room_id']; - final String eventId = data['event_id']; - final unread = ((data['counts'] is String - ? json.decode(data.tryGet('counts', '{}')) - : data.tryGet>( - 'counts', {})) as Map) - .tryGet('unread'); - if ((roomId?.isEmpty ?? true) || - (eventId?.isEmpty ?? true) || - unread == 0) { - Logs().i('[Push] New clearing push'); - await _flutterLocalNotificationsPlugin.cancelAll(); - return null; - } - if (matrix != null && matrix?.activeRoomId == roomId) { - return null; - } - Logs().i('[Push] New message received'); - // FIXME unable to init without context currently https://github.com/flutter/flutter/issues/67092 - // Locked on EN until issue resolved - final i18n = context == null ? L10nEn() : L10n.of(context); - - // Get the client - var client = matrix?.client; - var tempClient = false; - if (client == null) { - tempClient = true; - final platform = kIsWeb ? 'Web' : Platform.operatingSystem; - final clientName = 'FluffyChat $platform'; - client = Client(clientName, databaseBuilder: getDatabase)..init(); - Logs().i('[Push] Use a temp client'); - await client.onLoginStateChanged.stream - .firstWhere((l) => l == LoginState.logged) - .timeout( - Duration(seconds: 20), - ); - } - - final roomFuture = - client.onRoomUpdate.stream.where((u) => u.id == roomId).first; - final eventFuture = client.onEvent.stream - .where((u) => u.content['event_id'] == eventId) - .first; - - // Get the room - var room = client.getRoomById(roomId); - if (room == null) { - Logs().i('[Push] Wait for the room'); - await roomFuture.timeout(Duration(seconds: 5)); - Logs().i('[Push] Room found'); - room = client.getRoomById(roomId); - if (room == null) return null; - } else { - // cancel the future - // ignore: unawaited_futures - roomFuture.timeout(Duration(seconds: 0)).catchError((_) => null); - } - - // Get the event - var event = await client.database.getEventById(client.id, eventId, room); - if (event == null) { - Logs().i('[Push] Wait for the event'); - final eventUpdate = await eventFuture.timeout(Duration(seconds: 5)); - Logs().i('[Push] Event found'); - event = Event.fromJson(eventUpdate.content, room); - if (room == null) return null; - } else { - // cancel the future - // ignore: unawaited_futures - eventFuture.timeout(Duration(seconds: 0)).catchError((_) => null); - } - - // Count all unread events - var unreadEvents = 0; - client.rooms.forEach((Room room) => unreadEvents += room.notificationCount); - - // Calculate title - final title = unread > 1 - ? i18n.unreadMessagesInChats(unreadEvents.toString(), unread.toString()) - : i18n.unreadMessages(unreadEvents.toString()); - - // Calculate the body - final body = event.getLocalizedBody( - MatrixLocals(i18n), - withSenderNamePrefix: true, - hideReply: true, - ); - - // The person object for the android message style notification - final person = Person( - name: room.getLocalizedDisplayname(MatrixLocals(i18n)), - icon: room.avatar == null - ? null - : BitmapFilePathAndroidIcon( - await downloadAndSaveAvatar( - room.avatar, - client, - width: 126, - height: 126, - ), - ), - ); - - // Show notification - var androidPlatformChannelSpecifics = _getAndroidNotificationDetails( - styleInformation: MessagingStyleInformation( - person, - conversationTitle: title, - messages: [ - Message( - body, - event.originServerTs, - person, - ) - ], - ), - ticker: i18n.newMessageInFluffyChat, - ); - var iOSPlatformChannelSpecifics = IOSNotificationDetails(); - var platformChannelSpecifics = NotificationDetails( - android: androidPlatformChannelSpecifics, - iOS: iOSPlatformChannelSpecifics, - ); - await _flutterLocalNotificationsPlugin.show( - 0, - room.getLocalizedDisplayname(MatrixLocals(i18n)), - body, - platformChannelSpecifics, - payload: roomId); - - if (tempClient) { - await client.dispose(); - client = null; - Logs().i('[Push] Temp client disposed'); - } - return null; - } - - static Future _showDefaultNotification( - Map data) async { - try { - var flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - // Init notifications framework - var initializationSettingsAndroid = - AndroidInitializationSettings('notifications_icon'); - var initializationSettingsIOS = IOSInitializationSettings(); - var initializationSettings = InitializationSettings( - android: initializationSettingsAndroid, - iOS: initializationSettingsIOS, - ); - await flutterLocalNotificationsPlugin.initialize(initializationSettings); - - // FIXME unable to init without context currently https://github.com/flutter/flutter/issues/67092 - // Locked on en for now - //final l10n = L10n(Platform.localeName); - final l10n = L10nEn(); - String eventID = data['event_id']; - String roomID = data['room_id']; - final unread = ((data['counts'] is String - ? json.decode(data.tryGet('counts', '{}')) - : data.tryGet>( - 'counts', {})) as Map) - .tryGet('unread', 1); - await flutterLocalNotificationsPlugin.cancelAll(); - if (unread == 0 || roomID == null || eventID == null) { - return; - } - - // Display notification - var androidPlatformChannelSpecifics = _getAndroidNotificationDetails(); - var iOSPlatformChannelSpecifics = IOSNotificationDetails(); - var platformChannelSpecifics = NotificationDetails( - android: androidPlatformChannelSpecifics, - iOS: iOSPlatformChannelSpecifics, - ); - final title = l10n.unreadChats(unread.toString()); - await flutterLocalNotificationsPlugin.show( - 1, title, l10n.openAppToReadMessages, platformChannelSpecifics, - payload: roomID); - } catch (e, s) { - Logs().e('[Push] Error while processing background notification', e, s); - } - return Future.value(); - } - - static Future downloadAndSaveAvatar(Uri content, Client client, - {int width, int height}) async { - final thumbnail = width == null && height == null ? false : true; - final tempDirectory = (await getTemporaryDirectory()).path; - final prefix = thumbnail ? 'thumbnail' : ''; - var file = - File('$tempDirectory/${prefix}_${content.toString().split("/").last}'); - - if (!file.existsSync()) { - final url = thumbnail - ? content.getThumbnail(client, width: width, height: height) - : content.getDownloadLink(client); - var request = await HttpClient().getUrl(Uri.parse(url)); - var response = await request.close(); - var bytes = await consolidateHttpClientResponseBytes(response); - await file.writeAsBytes(bytes); - } - - return file.path; - } - - static void iOS_Permission() { - _firebaseMessaging.requestNotificationPermissions( - IosNotificationSettings(sound: true, badge: true, alert: true)); - _firebaseMessaging.onIosSettingsRegistered - .listen((IosNotificationSettings settings) { - Logs().i('Settings registered: $settings'); - }); - } - - static AndroidNotificationDetails _getAndroidNotificationDetails( - {MessagingStyleInformation styleInformation, String ticker}) { - final color = - context != null ? Theme.of(context).primaryColor : Color(0xFF5625BA); - - return AndroidNotificationDetails( - AppConfig.pushNotificationsChannelId, - AppConfig.pushNotificationsChannelName, - AppConfig.pushNotificationsChannelDescription, - styleInformation: styleInformation, - importance: Importance.max, - priority: Priority.high, - ticker: ticker, - color: color, - ); - } -} diff --git a/lib/utils/platform_infos.dart b/lib/utils/platform_infos.dart index 2be66086..e2bc0197 100644 --- a/lib/utils/platform_infos.dart +++ b/lib/utils/platform_infos.dart @@ -11,21 +11,26 @@ import '../app_config.dart'; abstract class PlatformInfos { static bool get isWeb => kIsWeb; + static bool get isLinux => Platform.isLinux; + static bool get isWindows => Platform.isWindows; + static bool get isMacOS => Platform.isMacOS; + static bool get isIOS => Platform.isIOS; + static bool get isAndroid => Platform.isAndroid; - static bool get isCupertinoStyle => - !kIsWeb && (Platform.isIOS || Platform.isMacOS); + static bool get isCupertinoStyle => !kIsWeb && (isIOS || isMacOS); - static bool get isMobile => !kIsWeb && (Platform.isAndroid || Platform.isIOS); + static bool get isMobile => !kIsWeb && (isAndroid || isIOS); /// For desktops which don't support ChachedNetworkImage yet - static bool get isBetaDesktop => - !kIsWeb && (Platform.isWindows || Platform.isLinux); + static bool get isBetaDesktop => !kIsWeb && (isWindows || isLinux); - static bool get isDesktop => - !kIsWeb && (Platform.isLinux || Platform.isWindows || Platform.isMacOS); + static bool get isDesktop => !kIsWeb && (isLinux || isWindows || isMacOS); static bool get usesTouchscreen => !isMobile; + static String get clientName => + '${AppConfig.applicationName} ${isWeb ? 'Web' : Platform.operatingSystem}'; + static Future getVersion() async { var version = kIsWeb ? 'Web' : 'Unknown'; try { diff --git a/lib/utils/plugins/background_push_plugin.dart b/lib/utils/plugins/background_push_plugin.dart new file mode 100644 index 00000000..60e2b86d --- /dev/null +++ b/lib/utils/plugins/background_push_plugin.dart @@ -0,0 +1,469 @@ +/* + * Famedly + * Copyright (C) 2020, 2021 Famedly GmbH + * Copyright (C) 2021 Fluffychat + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:fcm_shared_isolate/fcm_shared_isolate.dart'; +import 'package:flushbar/flushbar_helper.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:pedantic/pedantic.dart'; +import 'package:unifiedpush/unifiedpush.dart'; + +import '../../components/matrix.dart'; +import '../platform_infos.dart'; +import '../../app_config.dart'; +import '../../config/setting_keys.dart'; +import '../famedlysdk_store.dart'; + +class PreNotify { + PreNotify(this.roomId, this.eventId); + String roomId; + String eventId; +} + +class NoTokenException implements Exception { + String get cause => 'Cannot get firebase token'; +} + +class BackgroundPushPlugin { + static BackgroundPushPlugin _instance; + Client client; + MatrixState matrix; + String _fcmToken; + LoginState _loginState; + + final StreamController onPreNotify = StreamController.broadcast(); + + final pendingTests = >{}; + + void Function() _onMatrixInit; + + DateTime lastReceivedPush; + + BackgroundPushPlugin._(this.client) { + onLogin ??= + client.onLoginStateChanged.stream.listen(handleLoginStateChanged); + _firebaseMessaging.setListeners( + onMessage: _onFcmMessage, + onNewToken: _newFcmToken, + ); + UnifiedPush.setListeners( + onNewEndpoint: _newUpEndpoint, + onRegistrationFailed: _upUnregistered, + onRegistrationRefused: _upUnregistered, + onUnregistered: _upUnregistered, + onMessage: _onUpMessage, + ); + } + + factory BackgroundPushPlugin.clientOnly(Client client) { + _instance ??= BackgroundPushPlugin._(client); + return _instance; + } + + factory BackgroundPushPlugin(MatrixState matrix) { + final instance = BackgroundPushPlugin.clientOnly(matrix.client); + unawaited(instance.initMatrix(matrix)); + return instance; + } + + Future initMatrix(MatrixState matrix) async { + this.matrix = matrix; + _onMatrixInit?.call(); + _onMatrixInit = null; + } + + void handleLoginStateChanged(LoginState state) { + _loginState = state; + if (state == LoginState.logged && PlatformInfos.isMobile) { + setupPush(); + } + } + + void _newFcmToken(String token) { + _fcmToken = token; + if (_loginState == LoginState.logged && PlatformInfos.isMobile) { + setupPush(); + } + } + + final _firebaseMessaging = FcmSharedIsolate(); + + StreamSubscription onLogin; + + Future setupPusher({ + String gatewayUrl, + String token, + Set oldTokens, + }) async { + final clientName = PlatformInfos.clientName; + oldTokens ??= {}; + final pushers = await client.requestPushers().catchError((e) { + Logs().w('[Push] Unable to request pushers', e); + return []; + }); + var setNewPusher = false; + if (gatewayUrl != null && token != null && clientName != null) { + final currentPushers = pushers.where((pusher) => pusher.pushkey == token); + if (currentPushers.length == 1 && + currentPushers.first.kind == 'http' && + currentPushers.first.appId == AppConfig.pushNotificationsAppId && + currentPushers.first.appDisplayName == clientName && + currentPushers.first.deviceDisplayName == client.deviceName && + currentPushers.first.lang == 'en' && + currentPushers.first.data.url.toString() == gatewayUrl && + currentPushers.first.data.format == + AppConfig.pushNotificationsPusherFormat) { + Logs().i('[Push] Pusher already set'); + } else { + oldTokens.add(token); + if (client.isLogged()) { + setNewPusher = true; + } + } + } + for (final pusher in pushers) { + if (oldTokens.contains(pusher.pushkey)) { + pusher.kind = null; + try { + await client.setPusher( + pusher, + append: true, + ); + Logs().i('[Push] Removed legacy pusher for this device'); + } catch (err) { + Logs().w('[Push] Failed to remove old pusher', err); + } + } + } + if (setNewPusher) { + try { + await client.setPusher( + Pusher( + token, + AppConfig.pushNotificationsAppId, + clientName, + client.deviceName, + 'en', + PusherData( + url: Uri.parse(gatewayUrl), + format: AppConfig.pushNotificationsPusherFormat, + ), + kind: 'http', + ), + append: false, + ); + } catch (e, s) { + Logs().e('[Push] Unable to set pushers', e, s); + } + } + } + + Future setupPush() async { + if (_loginState != LoginState.logged || !PlatformInfos.isMobile) { + return; + } + if (!PlatformInfos.isIOS && + (await UnifiedPush.getDistributors()).isNotEmpty) { + await setupUp(); + } else { + await setupFirebase(); + } + } + + Future _noFcmWarning() async { + if (matrix?.context == null) { + return; + } + if (await matrix.store.getItemBool(SettingKeys.showNoGoogle, true)) { + await FlushbarHelper.createError( + message: matrix.l10n.noGoogleServicesWarning, + duration: Duration(seconds: 15), + ).show(matrix.context); + if (null == await matrix.store.getItem(SettingKeys.showNoGoogle)) { + await matrix.store.setItemBool(SettingKeys.showNoGoogle, false); + } + } + } + + Future setupFirebase() async { + if (_fcmToken?.isEmpty ?? true) { + try { + _fcmToken = await _firebaseMessaging.getToken(); + } catch (e, s) { + Logs().e('[Push] cannot get token', e, s); + await _noFcmWarning(); + return; + } + } + await setupPusher( + gatewayUrl: AppConfig.pushNotificationsGatewayUrl, + token: _fcmToken, + ); + + if (matrix == null) { + _onMatrixInit = sendTestMessageGUI; + } else if (kReleaseMode) { + // ignore: unawaited_futures + sendTestMessageGUI(); + } + } + + Future setupUp() async { + final store = matrix?.store ?? Store(); + if (!(await store.getItemBool(SettingKeys.unifiedPushRegistered, false))) { + Logs().i('[Push] UnifiedPush not registered, attempting to do so...'); + await UnifiedPush.registerAppWithDialog(); + } else { + // make sure the endpoint is up-to-date etc. + await _newUpEndpoint( + await store.getItem(SettingKeys.unifiedPushEndpoint)); + } + } + + Future _onFcmMessage(Map message) async { + Map data; + try { + data = Map.from(message['data'] ?? message); + await _onMessage(data); + } catch (e, s) { + Logs().e('[Push] Error while processing notification', e, s); + } + } + + Future _newUpEndpoint(String newEndpoint) async { + if (newEndpoint?.isEmpty ?? true) { + await _upUnregistered(); + return; + } + var endpoint = + 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify'; + try { + final url = Uri.parse(newEndpoint) + .replace( + path: '/_matrix/push/v1/notify', + query: '', + ) + .toString() + .split('?') + .first; + final res = json.decode(utf8.decode((await http.get(url)).bodyBytes)); + if (res['gateway'] == 'matrix') { + endpoint = url; + } + } catch (e) { + Logs().i( + '[Push] No self-hosted unified push gateway present: ' + newEndpoint); + } + Logs().i('[Push] UnifiedPush using endpoint ' + endpoint); + final oldTokens = {}; + try { + final fcmToken = await _firebaseMessaging.getToken(); + oldTokens.add(fcmToken); + } catch (_) {} + await setupPusher( + gatewayUrl: endpoint, + token: newEndpoint, + oldTokens: oldTokens, + ); + final store = matrix?.store ?? Store(); + await store.setItem(SettingKeys.unifiedPushEndpoint, newEndpoint); + await store.setItemBool(SettingKeys.unifiedPushRegistered, true); + } + + Future _upUnregistered() async { + Logs().i('[Push] Removing UnifiedPush endpoint...'); + final store = matrix?.store ?? Store(); + final oldEndpoint = await store.getItem(SettingKeys.unifiedPushEndpoint); + await store.setItemBool(SettingKeys.unifiedPushRegistered, false); + await store.deleteItem(SettingKeys.unifiedPushEndpoint); + if (matrix != null && (oldEndpoint?.isNotEmpty ?? false)) { + // remove the old pusher + await setupPusher( + oldTokens: {oldEndpoint}, + ); + } + } + + Future _onUpMessage(String message) async { + Map data; + try { + data = Map.from(json.decode(message)['notification']); + await _onMessage(data); + } catch (e, s) { + Logs().e('[Push] Error while processing notification', e, s); + } + } + + Future _onMessage(Map data) async { + try { + Logs().v('[Push] _onMessage'); + lastReceivedPush = DateTime.now(); + final roomId = data['room_id']; + final eventId = data['event_id']; + if (roomId == 'test') { + Logs().v('[Push] Test $eventId was successful!'); + pendingTests.remove(eventId)?.complete(); + return; + } + if (roomId != null && eventId != null) { + var giveUp = false; + var loaded = false; + final stopwatch = Stopwatch(); + stopwatch.start(); + final syncSubscription = client.onSync.stream.listen((r) { + if (stopwatch.elapsed.inSeconds >= 30) { + giveUp = true; + } + }); + final eventSubscription = client.onEvent.stream.listen((e) { + if (e.content['event_id'] == eventId) { + loaded = true; + } + }); + try { + if (!(await eventExists(roomId, eventId)) && !loaded) { + onPreNotify.add(PreNotify(roomId, eventId)); + do { + Logs().v('[Push] getting ' + roomId + ', event ' + eventId); + await client.oneShotSync(); + if (stopwatch.elapsed.inSeconds >= 60) { + giveUp = true; + } + } while (!loaded && !giveUp); + } + Logs().v('[Push] ' + + (giveUp ? 'gave up on ' : 'got ') + + roomId + + ', event ' + + eventId); + } finally { + await syncSubscription.cancel(); + await eventSubscription.cancel(); + } + } else { + if (client.syncPending) { + Logs().v('[Push] waiting for existing sync'); + await client.oneShotSync(); + } + Logs().v('[Push] single oneShotSync'); + await client.oneShotSync(); + } + } catch (e, s) { + Logs().e('[Push] Error proccessing push message: $e', s); + } + } + + Future eventExists(String roomId, String eventId) async { + final room = client.getRoomById(roomId); + if (room == null) return false; + return (await client.database.getEventById(client.id, eventId, room)) != + null; + } + + Future sendTestMessageGUI({bool verbose = false}) async { + try { + await sendTestMessage().timeout(Duration(seconds: 30)); + if (verbose) { + await FlushbarHelper.createSuccess( + message: + 'Push test was successful' /* matrix.l10n.pushTestSuccessful */) + .show(matrix.context); + } + } catch (e, s) { + var msg; +// final l10n = matrix.l10n; + if (e is SocketException) { + msg = 'Push server is unreachable'; +// msg = verbose ? l10n.pushServerUnreachable : null; + } else if (e is NoTokenException) { + msg = 'Push token is unavailable'; +// msg = verbose ? l10n.pushTokenUnavailable : null; + } else { + msg = 'Push failed'; +// msg = l10n.pushFail; + Logs().e('[Push] Test message failed: $e', s); + } + if (msg != null) { + await FlushbarHelper.createError(message: '$msg\n\n${e.toString()}') + .show(matrix.context); + } + return false; + } + return true; + } + + Future sendTestMessage() async { + final store = matrix?.store ?? Store(); + + if (!(await store.getItemBool(SettingKeys.unifiedPushRegistered, false)) && + (_fcmToken?.isEmpty ?? true)) { + throw NoTokenException(); + } + + final random = Random.secure(); + final randomId = + base64.encode(List.generate(12, (i) => random.nextInt(256))); + final completer = Completer(); + pendingTests[randomId] = completer; + + final endpoint = (await store.getItem(SettingKeys.unifiedPushEndpoint)) ?? + AppConfig.pushNotificationsGatewayUrl; + + try { + final resp = await http.post( + endpoint, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode( + { + 'notification': { + 'event_id': randomId, + 'room_id': 'test', + 'counts': { + 'unread': 1, + }, + 'devices': [ + { + 'app_id': AppConfig.pushNotificationsAppId, + 'pushkey': _fcmToken, + 'pushkey_ts': 12345678, + 'data': {}, + 'tweaks': {} + } + ] + } + }, + ), + ); + if (resp.statusCode < 200 || resp.statusCode >= 299) { + throw resp.body.isNotEmpty ? resp.body : resp.reasonPhrase; + } + } catch (_) { + pendingTests.remove(randomId); + rethrow; + } + + return completer.future; + } +} diff --git a/lib/utils/plugins/background_sync_plugin.dart b/lib/utils/plugins/background_sync_plugin.dart new file mode 100644 index 00000000..a64845ab --- /dev/null +++ b/lib/utils/plugins/background_sync_plugin.dart @@ -0,0 +1,54 @@ +/* + * Famedly + * Copyright (C) 2020, 2021 Famedly GmbH + * Copyright (C) 2021 Fluffychat + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import '../../components/matrix.dart'; + +class BackgroundSyncPlugin with WidgetsBindingObserver { + final Client client; + + BackgroundSyncPlugin._(this.client) { + if (kIsWeb) return; + final wb = WidgetsBinding.instance; + wb.addObserver(this); + didChangeAppLifecycleState(wb.lifecycleState); + } + + static BackgroundSyncPlugin _instance; + + factory BackgroundSyncPlugin.clientOnly(Client client) { + _instance ??= BackgroundSyncPlugin._(client); + return _instance; + } + + factory BackgroundSyncPlugin(MatrixState matrix) => + BackgroundSyncPlugin.clientOnly(matrix.client); + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + Logs().v('AppLifecycleState = $state'); + final foreground = state != AppLifecycleState.detached && + state != AppLifecycleState.paused; + client.backgroundSync = foreground; + client.syncPresence = foreground ? null : PresenceType.unavailable; + client.requestHistoryOnLimitedTimeline = !foreground; + } +} diff --git a/lib/utils/plugins/local_notification_plugin.dart b/lib/utils/plugins/local_notification_plugin.dart new file mode 100644 index 00000000..f986f826 --- /dev/null +++ b/lib/utils/plugins/local_notification_plugin.dart @@ -0,0 +1,366 @@ +/* + * Famedly + * Copyright (C) 2020, 2021 Famedly GmbH + * Copyright (C) 2021 Fluffychat + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; +import 'dart:convert'; + +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:universal_html/prefer_universal/html.dart' as darthtml; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_gen/gen_l10n/l10n_en.dart'; +import 'background_push_plugin.dart'; +import '../platform_infos.dart'; +import '../../components/matrix.dart'; +import '../matrix_locals.dart'; +import '../../app_config.dart'; +import '../famedlysdk_store.dart'; +import '../../config/setting_keys.dart'; + +class LocalNotificationPlugin { + Client client; + L10n l10n; + Future loadLocale() async { + // inspired by _lookupL10n in .dart_tool/flutter_gen/gen_l10n/l10n.dart + l10n ??= + matrix?.l10n ?? (await L10n.delegate.load(window.locale)) ?? L10nEn(); + } + + Future _initNotifications; + bool reinited = false; + MatrixState matrix; + + LocalNotificationPlugin._(this.client) { + Logs().v('[Notify] setup'); + onSync ??= client.onSync.stream.listen((r) => update()); + onEvent ??= client.onEvent.stream.listen((e) => event(e)); + onPreNotify ??= BackgroundPushPlugin.clientOnly(client) + .onPreNotify + .stream + .listen(preNotify); + if (kIsWeb) { + onFocusSub = darthtml.window.onFocus.listen((_) => webHasFocus = true); + onBlurSub = darthtml.window.onBlur.listen((_) => webHasFocus = false); + } + + _initNotifications = kIsWeb + ? registerDesktopNotifications() + : _flutterLocalNotificationsPlugin.initialize( + InitializationSettings( + android: AndroidInitializationSettings('notifications_icon'), + iOS: IOSInitializationSettings( + // constructor is called early (before runApp), + // so don't request permissions here. + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ), + ), + ); + + _initNotifications.then((_) => update()); + } + + static LocalNotificationPlugin _instance; + factory LocalNotificationPlugin.clientOnly(Client client) { + _instance ??= LocalNotificationPlugin._(client); + return _instance; + } + + factory LocalNotificationPlugin(MatrixState matrix) { + _instance ??= LocalNotificationPlugin._(matrix.client); + _instance.matrix = matrix; + return _instance; + } + + void update() { + // print('[Notify] updating'); + client.rooms.forEach((r) async { + try { + await _notification(r); + } catch (e, s) { + Logs().e('[Notify] Error processing update', e, s); + } + }); + } + + void reinit() { + if (reinited == true) { + return; + } + Logs().v('[Notify] reinit'); + _initNotifications = _flutterLocalNotificationsPlugin?.initialize( + InitializationSettings( + android: AndroidInitializationSettings('notifications_icon'), + iOS: IOSInitializationSettings(), + ), onSelectNotification: (String payload) async { + Logs().v('[Notify] Selected: $payload'); + _openRoom(payload); + return null; + }); + reinited = true; + } + + void event(EventUpdate e) { + notYetLoaded[e.roomID]?.removeWhere((x) => x == e.content['event_id']); + dirtyRoomIds.add(e.roomID); + } + + Map> notYetLoaded = {}; + + /// Rooms that has been changed since app start + final dirtyRoomIds = {}; + + void preNotify(PreNotify pn) { + final r = client.getRoomById(pn.roomId); + if (r == null) return; + notYetLoaded[pn.roomId] ??= {}; + notYetLoaded[pn.roomId].add(pn.eventId); + dirtyRoomIds.add(pn.roomId); + _notification(r); + } + + final _flutterLocalNotificationsPlugin = + kIsWeb ? null : FlutterLocalNotificationsPlugin(); + + StreamSubscription onSync; + StreamSubscription onEvent; + StreamSubscription onPreNotify; + + final roomEvent = {}; + + void _openRoom(String roomId) async { + if (matrix == null) { + return; + } + await matrix.widget.apl.currentState + .pushNamedAndRemoveUntilIsFirst('/rooms/$roomId'); + } + + Future _notification(Room room) async { + await _initNotifications; + + final dirty = dirtyRoomIds.remove(room.id); + + final notYet = notYetLoaded[room.id]?.length ?? 0; + if (notYet == 0 && + room.notificationCount == 0 && + room.membership != Membership.invite) { + if (!roomEvent.containsKey(room.id) || roomEvent[room.id] != null) { + Logs().v('[Notify] clearing ' + room.id); + roomEvent[room.id] = null; + final id = await mapRoomIdToInt(room.id); + await _flutterLocalNotificationsPlugin?.cancel(id); + } + return; + } + + final event = notYet == 0 ? room.lastEvent : null; + final eventId = event?.eventId ?? + (room.membership == Membership.invite ? 'invite' : ''); + if (roomEvent[room.id] == eventId) { + return; + } + + if (webHasFocus && room.id == matrix?.activeRoomId) { + return; + } + + await loadLocale(); + + // Calculate the body + final body = room.membership == Membership.invite + ? l10n.youAreInvitedToThisChat + : event?.getLocalizedBody( + MatrixLocals(l10n), + withSenderNamePrefix: !room.isDirectChat || + room.lastEvent.senderId == client.userID, + hideReply: true, + ) ?? + l10n.unreadMessages(room.notificationCount); + + Logs().v('[Notify] showing ' + room.id); + roomEvent[room.id] = eventId; + + // Show notification + if (_flutterLocalNotificationsPlugin != null) { + // The person object for the android message style notification + final person = Person( + name: room.getLocalizedDisplayname(MatrixLocals(l10n)), + icon: room.avatar == null || room.avatar.toString().isEmpty + ? null + : BitmapFilePathAndroidIcon( + await downloadAndSaveAvatar( + room.avatar, + client, + width: 126, + height: 126, + ), + ), + ); + var androidPlatformChannelSpecifics = AndroidNotificationDetails( + AppConfig.pushNotificationsChannelId, + AppConfig.pushNotificationsChannelName, + AppConfig.pushNotificationsChannelDescription, + styleInformation: MessagingStyleInformation( + person, + messages: [ + Message( + body, + event?.originServerTs ?? DateTime.now(), + person, + ) + ], + ), + importance: Importance.max, + priority: Priority.high, + when: event?.originServerTs?.millisecondsSinceEpoch, + onlyAlertOnce: !dirty); + var iOSPlatformChannelSpecifics = IOSNotificationDetails(); + var platformChannelSpecifics = NotificationDetails( + android: androidPlatformChannelSpecifics, + iOS: iOSPlatformChannelSpecifics, + ); + final id = await mapRoomIdToInt(room.id); + await _flutterLocalNotificationsPlugin.show( + id, + room.getLocalizedDisplayname(MatrixLocals(l10n)), + body, + platformChannelSpecifics, + payload: room.id, + ); + } else if (PlatformInfos.isWeb) { + sendDesktopNotification( + room.getLocalizedDisplayname(MatrixLocals(l10n)), + body, + roomId: room.id, + icon: event?.sender?.avatarUrl?.getThumbnail(client, + width: 64, height: 64, method: ThumbnailMethod.crop) ?? + room?.avatar?.getThumbnail(client, + width: 64, height: 64, method: ThumbnailMethod.crop), + ); + } else { + Logs().w('No platform support for notifications'); + } + } + + static Future downloadAndSaveAvatar(Uri content, Client client, + {int width, int height}) async { + final thumbnail = width == null && height == null ? false : true; + final tempDirectory = (await getTemporaryDirectory()).path; + final prefix = thumbnail ? 'thumbnail' : ''; + var file = + File('$tempDirectory/${prefix}_${content.toString().split("/").last}'); + + if (!file.existsSync()) { + final url = Uri.parse(thumbnail + ? content.getThumbnail(client, width: width, height: height) + : content.getDownloadLink(client)); + if (url.host.isEmpty) return ''; + final request = await HttpClient() + .getUrl(url) + .timeout(Duration(seconds: 5)) + .catchError((e) => null); + if (request == null) return ''; + final response = await request.close(); + var bytes = await consolidateHttpClientResponseBytes(response) + .timeout(Duration(seconds: 5)) + .catchError((e) => null); + if (bytes == null) return ''; + await file.writeAsBytes(bytes); + } + + return file.path; + } + + /// Workaround for the problem that local notification IDs must be int but we + /// sort by [roomId] which is a String. To make sure that we don't have duplicated + /// IDs we map the [roomId] to a number and store this number. + Future mapRoomIdToInt(String roomId) async { + final storage = matrix?.store ?? Store(); + final idMap = json.decode( + (await storage.getItem(SettingKeys.notificationCurrentIds)) ?? '{}'); + int currentInt; + try { + currentInt = idMap[roomId]; + } catch (_) { + currentInt = null; + } + if (currentInt != null) { + return currentInt; + } + currentInt = idMap.keys.length + 1; + idMap[roomId] = currentInt; + await storage.setItem( + SettingKeys.notificationCurrentIds, json.encode(idMap)); + return currentInt; + } + + StreamSubscription onFocusSub; + StreamSubscription onBlurSub; + + bool webHasFocus = true; + + void sendDesktopNotification( + String title, + String body, { + String icon, + String roomId, + }) async { + try { + darthtml.AudioElement() + ..src = 'assets/assets/sounds/pop.wav' + ..autoplay = true + ..load(); + final notification = darthtml.Notification( + title, + body: body, + icon: icon, + ); + notification.onClick.listen((e) => _openRoom(roomId)); + } catch (e, s) { + Logs().e('[Notify] Error sending desktop notification', e, s); + } + } + + Future registerDesktopNotifications() async { + await client.onSync.stream.first; + await darthtml.Notification.requestPermission(); + onSync ??= client.onSync.stream.listen(updateTabTitle); + } + + void updateTabTitle(dynamic sync) { + var unreadEvents = 0; + client.rooms.forEach((Room room) { + if (room.membership == Membership.invite || room.notificationCount > 0) { + unreadEvents++; + } + }); + if (unreadEvents > 0) { + darthtml.document.title = '($unreadEvents) FluffyChat'; + } else { + darthtml.document.title = 'FluffyChat'; + } + } +} diff --git a/lib/views/homeserver_picker.dart b/lib/views/homeserver_picker.dart index 583d0716..c4f66824 100644 --- a/lib/views/homeserver_picker.dart +++ b/lib/views/homeserver_picker.dart @@ -37,7 +37,7 @@ class _HomeserverPickerState extends State { type: AuthenticationTypes.token, userIdentifierType: null, token: token, - initialDeviceDisplayName: Matrix.of(context).clientName, + initialDeviceDisplayName: PlatformInfos.clientName, ), ); } diff --git a/lib/views/login.dart b/lib/views/login.dart index 7754d9be..ebf08d42 100644 --- a/lib/views/login.dart +++ b/lib/views/login.dart @@ -8,6 +8,7 @@ import 'package:fluffychat/components/matrix.dart'; import 'package:flushbar/flushbar_helper.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; +import '../utils/platform_infos.dart'; class Login extends StatefulWidget { @override @@ -44,7 +45,7 @@ class _LoginState extends State { await matrix.client.login( user: usernameController.text, password: passwordController.text, - initialDeviceDisplayName: matrix.clientName); + initialDeviceDisplayName: PlatformInfos.clientName); } on MatrixException catch (exception) { setState(() => passwordError = exception.errorMessage); return setState(() => loading = false); diff --git a/lib/views/sign_up_password.dart b/lib/views/sign_up_password.dart index ec3ab52c..d42aab9c 100644 --- a/lib/views/sign_up_password.dart +++ b/lib/views/sign_up_password.dart @@ -8,6 +8,7 @@ import 'package:fluffychat/components/matrix.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../utils/platform_infos.dart'; class SignUpPassword extends StatefulWidget { final MatrixFile avatar; @@ -43,7 +44,7 @@ class _SignUpPasswordState extends State { await matrix.client.register( username: widget.username, password: passwordController.text, - initialDeviceDisplayName: matrix.clientName, + initialDeviceDisplayName: PlatformInfos.clientName, auth: auth, ); await waitForLogin; diff --git a/pubspec.lock b/pubspec.lock index d662b8a7..b0c9cb21 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -227,6 +227,15 @@ packages: url: "https://gitlab.com/famedly/famedlysdk.git" source: git version: "0.0.1" + fcm_shared_isolate: + dependency: "direct main" + description: + path: "." + ref: log + resolved-ref: "646eab0821cd94aaa221c3e6c700c62918dc41f3" + url: "https://gitlab.com/famedly/libraries/fcm_shared_isolate.git" + source: git + version: "0.0.1" ffi: dependency: transitive description: @@ -269,34 +278,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.1" - firebase: - dependency: transitive - description: - name: firebase - url: "https://pub.dartlang.org" - source: hosted - version: "7.3.0" firebase_core: dependency: transitive description: name: firebase_core url: "https://pub.dartlang.org" source: hosted - version: "0.5.0+1" + version: "0.5.3" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.1.0" firebase_core_web: dependency: transitive description: name: firebase_core_web url: "https://pub.dartlang.org" source: hosted - version: "0.2.0" + version: "0.2.1+1" firebase_messaging: dependency: "direct main" description: @@ -1160,11 +1162,9 @@ packages: unifiedpush: dependency: "direct main" description: - path: "." - ref: main - resolved-ref: "9ad5360bb63021f366d35facf217f053d465b46c" - url: "https://github.com/UnifiedPush/flutter-connector.git" - source: git + path: "/home/sorunome/repos/flutter-connector" + relative: false + source: path version: "0.0.1" universal_html: dependency: "direct main" diff --git a/pubspec.yaml b/pubspec.yaml index 9d7396da..f780c6f2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,9 +16,16 @@ dependencies: ref: main unifiedpush: + path: /home/sorunome/repos/flutter-connector +# git: +# url: https://github.com/UnifiedPush/flutter-connector.git +# ref: main + + # Firebase Notifications + fcm_shared_isolate: git: - url: https://github.com/UnifiedPush/flutter-connector.git - ref: main + url: https://gitlab.com/famedly/libraries/fcm_shared_isolate.git + ref: log cupertino_icons: any localstorage: ^3.0.6+9