diff --git a/android/app/build.gradle b/android/app/build.gradle index 8aa8b755..8ca97b18 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -85,6 +85,7 @@ 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()){ diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a26dd12c..3411808c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -74,6 +74,24 @@ + + + + + + + + + + + + + + + + + (context, listen: false); } -class MatrixState extends State { - Client client; +class MatrixState extends State with WidgetsBindingObserver { + FluffyClient client; Store store = Store(); @override BuildContext get context => widget.context; + BackgroundPush _backgroundPush; + Map get shareContent => _shareContent; set shareContent(Map content) { _shareContent = content; @@ -75,15 +78,7 @@ class MatrixState extends State { final StreamController> onShareContentChanged = StreamController.broadcast(); - String activeRoomId; File wallpaper; - String clientName; - - void clean() async { - if (!kIsWeb) return; - - await store.deleteItem(clientName); - } void _initWithStore() async { try { @@ -181,7 +176,7 @@ class MatrixState extends State { void _showLocalNotification(EventUpdate eventUpdate) async { final roomId = eventUpdate.roomID; - if (webHasFocus && activeRoomId == roomId) return; + if (webHasFocus && client.activeRoomId == roomId) return; final room = client.getRoomById(roomId); if (room.notificationCount == 0) return; final event = Event.fromJson(eventUpdate.content, room); @@ -228,6 +223,7 @@ class MatrixState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); initMatrix(); if (PlatformInfos.isWeb) { initConfig().then((_) => initSettings()); @@ -261,28 +257,7 @@ 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 - }, - ); + client = FluffyClient(); LoadingDialog.defaultTitle = L10n.of(context).loadingPleaseWait; LoadingDialog.defaultBackLabel = L10n.of(context).close; LoadingDialog.defaultOnError = (Object e) => e.toLocalizedString(context); @@ -346,16 +321,10 @@ 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); - if (kIsWeb || Platform.isLinux) { + if (PlatformInfos.isWeb || PlatformInfos.isLinux) { client.onSync.stream.first.then((s) { html.Notification.requestPermission(); onNotification ??= client.onEvent.stream @@ -367,6 +336,26 @@ class MatrixState extends State { .listen(_showLocalNotification); }); } + + if (PlatformInfos.isMobile) { + _backgroundPush = BackgroundPush(client, context, widget.apl); + } + } + + bool _firstStartup = true; + + @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; + if (_firstStartup) { + _firstStartup = false; + _backgroundPush?.setupPush(); + } } void initSettings() { @@ -399,12 +388,16 @@ class MatrixState extends State { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); + onRoomKeyRequestSub?.cancel(); onKeyVerificationRequestSub?.cancel(); onLoginStateChanged?.cancel(); onNotification?.cancel(); onFocusSub?.cancel(); onBlurSub?.cancel(); + _backgroundPush?.onLogin?.cancel(); + super.dispose(); } diff --git a/lib/config/setting_keys.dart b/lib/config/setting_keys.dart index bcf6ac80..7206729c 100644 --- a/lib/config/setting_keys.dart +++ b/lib/config/setting_keys.dart @@ -16,4 +16,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..d8111cda 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,12 +19,24 @@ import 'package:universal_html/prefer_universal/html.dart' as html; import 'components/matrix.dart'; import 'config/themes.dart'; import 'app_config.dart'; +import 'utils/fluffy_client.dart'; +import 'utils/platform_infos.dart'; +import 'utils/background_push.dart'; void main() async { + // Our background push shared isolate accesses flutter-internal things very early in the startup proccess + // To make sure that the parts of flutter needed are started up already, we need to ensure that the + // widget bindings are initialized already. + WidgetsFlutterBinding.ensureInitialized(); SystemChrome.setSystemUIOverlayStyle( SystemUiOverlayStyle(statusBarColor: Colors.transparent)); FlutterError.onError = (FlutterErrorDetails details) => Zone.current.handleUncaughtError(details.exception, details.stack); + + if (PlatformInfos.isMobile) { + BackgroundPush.clientOnly(FluffyClient()); + } + runZonedGuarded( () => runApp(PlatformInfos.isMobile ? AppLock( diff --git a/lib/utils/background_push.dart b/lib/utils/background_push.dart new file mode 100644 index 00000000..0a659a01 --- /dev/null +++ b/lib/utils/background_push.dart @@ -0,0 +1,811 @@ +/* + * 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:ui'; +import 'dart:math'; + +import 'package:adaptive_page_layout/adaptive_page_layout.dart'; +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:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:unifiedpush/unifiedpush.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 'platform_infos.dart'; +import '../app_config.dart'; +import '../config/setting_keys.dart'; +import 'famedlysdk_store.dart'; +import 'fluffy_client.dart'; +import 'matrix_locals.dart'; + +class NoTokenException implements Exception { + String get cause => 'Cannot get firebase token'; +} + +class BackgroundPush { + static BackgroundPush _instance; + final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); + FluffyClient client; + BuildContext context; + GlobalKey apl; + String _fcmToken; + LoginState _loginState; + L10n l10n; + Store _store; + Store get store => _store ??= Store(); + Future loadLocale() async { + // inspired by _lookupL10n in .dart_tool/flutter_gen/gen_l10n/l10n.dart + l10n ??= (context != null ? L10n.of(context) : null) ?? + (await L10n.delegate.load(window.locale)) ?? + L10nEn(); + } + + final pendingTests = >{}; + + void Function() _onContextInit; + + DateTime lastReceivedPush; + + BackgroundPush._(this.client) { + onLogin ??= + client.onLoginStateChanged.stream.listen(handleLoginStateChanged); + onRoomSync ??= client.onSync.stream + .where((s) => s.hasRoomUpdate) + .listen((s) => _onClearingPush(getFromServer: false)); + _firebaseMessaging.setListeners( + onMessage: _onFcmMessage, + onNewToken: _newFcmToken, + ); + UnifiedPush.initializeWithReceiver( + onNewEndpoint: _newUpEndpoint, + onRegistrationFailed: _upUnregistered, + onRegistrationRefused: _upUnregistered, + onUnregistered: _upUnregistered, + onMessage: _onUpMessage, + ); + } + + factory BackgroundPush.clientOnly(FluffyClient client) { + _instance ??= BackgroundPush._(client); + return _instance; + } + + factory BackgroundPush(FluffyClient _client, BuildContext _context, + GlobalKey _apl) { + final instance = BackgroundPush.clientOnly(_client); + instance.context = _context; + instance.apl = _apl; + instance.fullInit(); + return instance; + } + + Future fullInit() async { + _onContextInit?.call(); + _onContextInit = null; + // ignore: unawaited_futures + setupPush(); + } + + void handleLoginStateChanged(LoginState state) { + _loginState = state; + setupPush(); + } + + void _newFcmToken(String token) { + _fcmToken = token; + setupPush(); + } + + final _firebaseMessaging = FcmSharedIsolate(); + + StreamSubscription onLogin; + StreamSubscription onRoomSync; + + Future setupPusher({ + String gatewayUrl, + String token, + Set oldTokens, + bool useDeviceSpecificAppId = false, + }) 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; + // Just the plain app id, we add the .data_message suffix later + var appId = AppConfig.pushNotificationsAppId; + // we need the deviceAppId to remove potential legacy UP pusher + var deviceAppId = '$appId.${client.deviceID}'; + // appId may only be up to 64 chars as per spec + if (deviceAppId.length > 64) { + deviceAppId = deviceAppId.substring(0, 64); + } + if (!useDeviceSpecificAppId && PlatformInfos.isAndroid) { + appId += '.data_message'; + } + final thisAppId = useDeviceSpecificAppId ? deviceAppId : appId; + 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 == thisAppId && + 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 ((token != null && + pusher.pushkey != token && + deviceAppId == pusher.appId) || + 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, + thisAppId, + 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 || + context == null) { + return; + } + if (!PlatformInfos.isIOS && + (await UnifiedPush.getDistributors()).isNotEmpty) { + await setupUp(); + } else { + await setupFirebase(); + } + } + + Future _noFcmWarning() async { + if (context == null) { + return; + } + if (await store.getItemBool(SettingKeys.showNoGoogle, true)) { + await loadLocale(); + await FlushbarHelper.createError( + message: l10n.noGoogleServicesWarning, + duration: Duration(seconds: 15), + ).show(context); + if (null == await store.getItem(SettingKeys.showNoGoogle)) { + await 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 (context == null) { + _onContextInit = sendTestMessageGUI; + } else if (kReleaseMode) { + // ignore: unawaited_futures + sendTestMessageGUI(); + } + } + + Future goToRoom(String roomId) async { + try { + if (apl == null) { + return; + } + await apl.currentState.pushNamedAndRemoveUntilIsFirst('/rooms/$roomId'); + } catch (e, s) { + Logs().e('[Push] Failed to open room', e, s); + } + } + + bool _notificationsPluginSetUp = false; + Future setupLocalNotificationsPlugin() async { + if (_notificationsPluginSetUp) { + return; + } + + // 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); + }); + + _notificationsPluginSetUp = true; + } + + Future setupUp() async { + await setupLocalNotificationsPlugin(); + 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); + await _showDefaultNotification(data); + } + } + + 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, + useDeviceSpecificAppId: true, + ); + await store.setItem(SettingKeys.unifiedPushEndpoint, newEndpoint); + await store.setItemBool(SettingKeys.unifiedPushRegistered, true); + } + + Future _upUnregistered() async { + Logs().i('[Push] Removing UnifiedPush endpoint...'); + final oldEndpoint = await store.getItem(SettingKeys.unifiedPushEndpoint); + await store.setItemBool(SettingKeys.unifiedPushRegistered, false); + await store.deleteItem(SettingKeys.unifiedPushEndpoint); + if (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); + await _showDefaultNotification(data); + } + } + + Future _onMessage(Map data) async { + 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; + } + 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) { + await _onClearingPush(); + return; + } + 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) { + do { + Logs().v('[Push] getting ' + roomId + ', event ' + eventId); + await client + .oneShotSync() + .catchError((e) => Logs().v('[Push] Error one-shot syncing', e)); + 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(); + } + await _showNotification(roomId, eventId); + } + + 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; + } + + /// 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. + Map idMap; + Future _loadIdMap() async { + idMap ??= Map.from(json.decode( + (await store.getItem(SettingKeys.notificationCurrentIds)) ?? '{}')); + } + + Future mapRoomIdToInt(String roomId) async { + await _loadIdMap(); + int currentInt; + try { + currentInt = idMap[roomId]; + } catch (_) { + currentInt = null; + } + if (currentInt != null) { + return currentInt; + } + currentInt = 0; + while (idMap.values.contains(currentInt)) { + currentInt++; + } + idMap[roomId] = currentInt; + await store.setItem(SettingKeys.notificationCurrentIds, json.encode(idMap)); + return currentInt; + } + + bool _clearingPushLock = false; + Future _onClearingPush({bool getFromServer = true}) async { + if (_clearingPushLock) { + return; + } + try { + _clearingPushLock = true; + Iterable emptyRooms; + if (getFromServer) { + Logs().v('[Push] Got new clearing push'); + var syncErrored = false; + if (client.syncPending) { + Logs().v('[Push] waiting for existing sync'); + // we need to catchError here as the Future might be in a different execution zone + await client.oneShotSync().catchError((e) { + syncErrored = true; + Logs().v('[Push] Error one-shot syncing', e); + }); + } + if (!syncErrored) { + Logs().v('[Push] single oneShotSync'); + // we need to catchError here as the Future might be in a different execution zone + await client.oneShotSync().catchError((e) { + syncErrored = true; + Logs().v('[Push] Error one-shot syncing', e); + }); + if (!syncErrored) { + emptyRooms = client.rooms + .where((r) => r.notificationCount == 0) + .map((r) => r.id); + } + } + if (syncErrored) { + try { + Logs().v( + '[Push] failed to sync for fallback push, fetching notifications endpoint...'); + final notifications = await client.requestNotifications(limit: 20); + final notificationRooms = + notifications.notifications.map((n) => n.roomId).toSet(); + emptyRooms = client.rooms + .where((r) => !notificationRooms.contains(r.id)) + .map((r) => r.id); + } catch (e) { + Logs().v( + '[Push] failed to fetch pending notifications for clearing push, falling back...', + e); + emptyRooms = client.rooms + .where((r) => r.notificationCount == 0) + .map((r) => r.id); + } + } + } else { + emptyRooms = client.rooms + .where((r) => r.notificationCount == 0) + .map((r) => r.id); + } + await _loadIdMap(); + var changed = false; + for (final roomId in emptyRooms) { + if (idMap[roomId] != null) { + final id = idMap[roomId]; + idMap.remove(roomId); + changed = true; + await _flutterLocalNotificationsPlugin?.cancel(id); + } + } + if (changed) { + await store.setItem( + SettingKeys.notificationCurrentIds, json.encode(idMap)); + } + } finally { + _clearingPushLock = false; + } + } + + Future _showNotification(String roomId, String eventId) async { + await setupLocalNotificationsPlugin(); + final room = client.getRoomById(roomId); + await room.postLoad(); + final event = await client.database.getEventById(client.id, eventId, room); + if (room == null) { + throw 'Room not found'; + } + + if (((client.activeRoomId?.isNotEmpty ?? false) && + client.activeRoomId == room.id && + client.syncPresence == null) || + (event != null && room.notificationCount == 0)) { + return; + } + + // load the locale + await loadLocale(); + + // Count all unread events + var unreadEvents = 0; + client.rooms.forEach((Room room) => unreadEvents += room.notificationCount); + + // Calculate title + final title = l10n.unreadMessages(room.notificationCount.toString()); + + // Calculate the body + final body = event?.getLocalizedBody( + MatrixLocals(l10n), + withSenderNamePrefix: true, + hideReply: true, + ) ?? + l10n.openAppToReadMessages; + + // The person object for the android message style notification + final avatar = room.avatar == null + ? null + : await downloadAndSaveAvatar( + room.avatar, + client, + width: 126, + height: 126, + ); + final person = Person( + name: room.getLocalizedDisplayname(MatrixLocals(l10n)), + icon: avatar == null ? null : BitmapFilePathAndroidIcon(avatar), + ); + + // Show notification + var androidPlatformChannelSpecifics = _getAndroidNotificationDetails( + styleInformation: MessagingStyleInformation( + person, + conversationTitle: title, + messages: [ + Message( + body, + event?.originServerTs ?? DateTime.now(), + person, + ) + ], + ), + ticker: l10n.newMessageInFluffyChat, + ); + var iOSPlatformChannelSpecifics = IOSNotificationDetails(); + var platformChannelSpecifics = NotificationDetails( + android: androidPlatformChannelSpecifics, + iOS: iOSPlatformChannelSpecifics, + ); + await _flutterLocalNotificationsPlugin.show( + await mapRoomIdToInt(room.id), + room.getLocalizedDisplayname(MatrixLocals(l10n)), + body, + platformChannelSpecifics, + payload: roomId, + ); + } + + Future _showDefaultNotification(Map data) async { + try { + await setupLocalNotificationsPlugin(); + + await loadLocale(); + 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); + if (unread == 0 || roomId == null || eventId == null) { + await _onClearingPush(); + 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( + await mapRoomIdToInt(roomId), + title, + l10n.openAppToReadMessages, + platformChannelSpecifics, + payload: roomId, + ); + } catch (e, s) { + Logs().e('[Push] Error while processing background notification', e, s); + } + } + + 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(); + if (response.statusCode >= 300) { + // we are not in the 2xx range + return null; + } + var bytes = await consolidateHttpClientResponseBytes(response); + await file.writeAsBytes(bytes); + } + + return file.path; + } + + AndroidNotificationDetails _getAndroidNotificationDetails( + {MessagingStyleInformation styleInformation, String ticker}) { + final color = (context != null ? Theme.of(context).primaryColor : null) ?? + Color(0xFF5625BA); + + return AndroidNotificationDetails( + AppConfig.pushNotificationsChannelId, + AppConfig.pushNotificationsChannelName, + AppConfig.pushNotificationsChannelDescription, + styleInformation: styleInformation, + importance: Importance.max, + priority: Priority.high, + ticker: ticker, + color: color, + ); + } + + Future sendTestMessageGUI({bool verbose = false}) async { + try { + await sendTestMessage().timeout(Duration(seconds: 30)); + if (verbose) { + await FlushbarHelper.createSuccess( + message: + 'Push test was successful' /* l10n.pushTestSuccessful */) + .show(context); + } + } catch (e, s) { + var msg; +// final l10n = 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(context); + } + return false; + } + return true; + } + + Future sendTestMessage() async { + 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(); + if (PlatformInfos.isIOS) { + // don't expect a reply, because fcm_shared_isolate can't receive on iOS + completer.complete(); + } else { + 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/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/fluffy_client.dart b/lib/utils/fluffy_client.dart new file mode 100644 index 00000000..bfef06f7 --- /dev/null +++ b/lib/utils/fluffy_client.dart @@ -0,0 +1,34 @@ +import 'package:famedlysdk/famedlysdk.dart'; +import 'package:famedlysdk/encryption.dart'; +import 'platform_infos.dart'; +import 'famedlysdk_store.dart'; + +class FluffyClient extends Client { + static final FluffyClient _instance = FluffyClient._internal(); + + /// The ID of the currently active room, if there is one. May be null or emtpy + String activeRoomId; + + factory FluffyClient() { + return _instance; + } + + FluffyClient._internal() + : super( + PlatformInfos.clientName, + enableE2eeRecovery: true, + verificationMethods: { + KeyVerificationMethod.numbers, + if (PlatformInfos.isMobile || PlatformInfos.isLinux) + KeyVerificationMethod.emoji, + }, + importantStateEvents: { + 'im.ponies.room_emotes', // we want emotes to work properly + }, + databaseBuilder: getDatabase, + supportedLoginTypes: { + AuthenticationTypes.password, + if (PlatformInfos.isMobile) AuthenticationTypes.sso + }, + ); +} diff --git a/lib/utils/platform_infos.dart b/lib/utils/platform_infos.dart index 2be66086..ce7e4bef 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 => !kIsWeb && Platform.isLinux; + static bool get isWindows => !kIsWeb && Platform.isWindows; + static bool get isMacOS => !kIsWeb && Platform.isMacOS; + static bool get isIOS => !kIsWeb && Platform.isIOS; + static bool get isAndroid => !kIsWeb && Platform.isAndroid; - static bool get isCupertinoStyle => - !kIsWeb && (Platform.isIOS || Platform.isMacOS); + static bool get isCupertinoStyle => isIOS || isMacOS; - static bool get isMobile => !kIsWeb && (Platform.isAndroid || Platform.isIOS); + static bool get isMobile => isAndroid || isIOS; /// For desktops which don't support ChachedNetworkImage yet - static bool get isBetaDesktop => - !kIsWeb && (Platform.isWindows || Platform.isLinux); + static bool get isBetaDesktop => isWindows || isLinux; - static bool get isDesktop => - !kIsWeb && (Platform.isLinux || Platform.isWindows || Platform.isMacOS); + static bool get isDesktop => 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/views/chat.dart b/lib/views/chat.dart index fb225b81..82634659 100644 --- a/lib/views/chat.dart +++ b/lib/views/chat.dart @@ -185,7 +185,7 @@ class _ChatState extends State { void dispose() { timeline?.cancelSubscriptions(); timeline = null; - matrix.activeRoomId = ''; + matrix.client.activeRoomId = ''; super.dispose(); } @@ -509,7 +509,7 @@ class _ChatState extends State { ), ); } - matrix.activeRoomId = widget.id; + matrix.client.activeRoomId = widget.id; if (room.membership == Membership.invite) { showFutureLoadingDialog(context: context, future: () => room.join()); diff --git a/lib/views/homeserver_picker.dart b/lib/views/homeserver_picker.dart index 5b46c277..f962d91e 100644 --- a/lib/views/homeserver_picker.dart +++ b/lib/views/homeserver_picker.dart @@ -38,7 +38,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 53892d6f..1727e80d 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'; import '../app_config.dart'; @@ -46,7 +47,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 8d49a2bd..5d34d5f6 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'; import '../app_config.dart'; @@ -45,7 +46,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 3ef81523..85860843 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: ios + resolved-ref: "86519130e5e122a20fdd31de34013d62a88f106d" + 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: @@ -1161,8 +1163,8 @@ packages: dependency: "direct main" description: path: "." - ref: main - resolved-ref: f91b62772bd000863d1df2218198a83e71e6f87c + ref: "8edc09a87d726d912926ac612e209573452c1273" + resolved-ref: "8edc09a87d726d912926ac612e209573452c1273" url: "https://github.com/UnifiedPush/flutter-connector.git" source: git version: "0.0.1" diff --git a/pubspec.yaml b/pubspec.yaml index 9d7396da..c42f2119 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,13 @@ dependencies: unifiedpush: git: url: https://github.com/UnifiedPush/flutter-connector.git - ref: main + ref: 8edc09a87d726d912926ac612e209573452c1273 + + # Firebase Notifications + fcm_shared_isolate: + git: + url: https://gitlab.com/famedly/libraries/fcm_shared_isolate.git + ref: ios cupertino_icons: any localstorage: ^3.0.6+9