diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1d25d5d..3aa394f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,9 @@ additional functionality it is fine to subclass or reimplement FlutterApplication and put your custom class here. --> + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable-hdpi/notifications_icon.png b/android/app/src/main/res/drawable-hdpi/notifications_icon.png new file mode 100644 index 0000000..1a3a506 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/notifications_icon.png differ diff --git a/android/app/src/main/res/drawable-mdpi/notifications_icon.png b/android/app/src/main/res/drawable-mdpi/notifications_icon.png new file mode 100644 index 0000000..1a3a506 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/notifications_icon.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/notifications_icon.png b/android/app/src/main/res/drawable-xhdpi/notifications_icon.png new file mode 100644 index 0000000..1a3a506 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/notifications_icon.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/notifications_icon.png b/android/app/src/main/res/drawable-xxhdpi/notifications_icon.png new file mode 100644 index 0000000..1a3a506 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/notifications_icon.png differ diff --git a/android/app/src/main/res/drawable/notifications_icon.xml b/android/app/src/main/res/drawable/notifications_icon.xml new file mode 100644 index 0000000..8f7a59f --- /dev/null +++ b/android/app/src/main/res/drawable/notifications_icon.xml @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/lib/components/matrix.dart b/lib/components/matrix.dart index 9404c3d..b70a296 100644 --- a/lib/components/matrix.dart +++ b/lib/components/matrix.dart @@ -3,10 +3,14 @@ import 'dart:convert'; import 'dart:io'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:famedlysdk/famedlysdk.dart'; +import 'package:fluffychat/utils/app_route.dart'; import 'package:fluffychat/utils/sqflite_store.dart'; +import 'package:fluffychat/views/chat.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:localstorage/localstorage.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:toast/toast.dart'; class Matrix extends StatefulWidget { @@ -35,6 +39,10 @@ class MatrixState extends State { BuildContext context; FirebaseMessaging _firebaseMessaging = FirebaseMessaging(); + FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); + + String activeRoomId; /// Used to load the old account if there is no store available. void loadAccount() async { @@ -129,10 +137,28 @@ class MatrixState extends State { hideLoadingDialog() => Navigator.of(_loadingDialogContext)?.pop(); - StreamSubscription onSetupFirebase; + Future downloadAndSaveContent(MxContent content, + {int width, int height, ThumbnailMethod method}) async { + final bool thumbnail = width == null && height == null ? false : true; + final String tempDirectory = (await getTemporaryDirectory()).path; + final String prefix = thumbnail ? "thumbnail" : ""; + File file = File('$tempDirectory/${prefix}_${content.mxc.split("/").last}'); - void setupFirebase(LoginState login) async { - if (login != LoginState.logged) return; + if (!file.existsSync()) { + final url = thumbnail + ? content.getThumbnail(client, + width: width, height: height, method: method) + : 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; + } + + Future setupFirebase() async { if (Platform.isIOS) iOS_Permission(); final String token = await _firebaseMessaging.getToken(); @@ -155,14 +181,157 @@ class MatrixState extends State { format: "event_id_only", ); + Function goToRoom = (dynamic message) async { + try { + String roomId; + if (message is String) { + roomId = message; + } else if (message is Map) { + roomId = message["data"]["room_id"]; + } + if (roomId?.isEmpty ?? true) throw ("Bad roomId"); + await Navigator.of(context).pushAndRemoveUntil( + AppRoute.defaultRoute( + context, + Chat(roomId), + ), + (r) => r.isFirst); + } catch (_) { + Toast.show("Failed to open chat...", context); + print(_); + } + }; + + // 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) { + print("onDidReceiveLocalNotification: $i $a $b $c"); + return null; + }); + var initializationSettings = InitializationSettings( + initializationSettingsAndroid, initializationSettingsIOS); + await _flutterLocalNotificationsPlugin.initialize(initializationSettings, + onSelectNotification: goToRoom); + _firebaseMessaging.configure( - onResume: (Map message) async { - print('on resume $message'); - }, - onLaunch: (Map message) async { - print('on launch $message'); + onMessage: (Map message) async { + try { + final String roomId = message["data"]["room_id"]; + final String eventId = message["data"]["event_id"]; + final int unread = json.decode(message["data"]["counts"])["unread"]; + if ((roomId?.isEmpty ?? true) || + (eventId?.isEmpty ?? true) || + unread == 0) { + await _flutterLocalNotificationsPlugin.cancelAll(); + return null; + } + if (activeRoomId == roomId) return null; + + // Get the room + Room room = client.getRoomById(roomId); + if (room == null) { + await client.onRoomUpdate.stream + .where((u) => u.id == roomId) + .first + .timeout(Duration(seconds: 10)); + room = client.getRoomById(roomId); + if (room == null) return null; + } + + // Get the event + Event event = await client.store.getEventById(eventId, room); + if (event == null) { + final EventUpdate eventUpdate = await client.onEvent.stream + .where((u) => u.content["event_id"] == eventId) + .first + .timeout(Duration(seconds: 10)); + event = Event.fromJson(eventUpdate.content, room); + if (room == null) return null; + } + + // Count all unread events + int unreadEvents = 0; + client.rooms + .forEach((Room room) => unreadEvents += room.notificationCount); + + // Calculate title + final String title = unread > 1 + ? "$unreadEvents unread messages in $unread chats" + : "$unreadEvents unread messages"; + + // Calculate the body + String body; + switch (event.messageType) { + case MessageTypes.Image: + body = "${event.sender.calcDisplayname()} sent a picture"; + break; + case MessageTypes.File: + body = "${event.sender.calcDisplayname()} sent a file"; + break; + case MessageTypes.Audio: + body = "${event.sender.calcDisplayname()} sent an audio"; + break; + case MessageTypes.Video: + body = "${event.sender.calcDisplayname()} sent a video"; + break; + default: + body = "${event.sender.calcDisplayname()}: ${event.getBody()}"; + break; + } + + // The person object for the android message style notification + final person = Person( + name: room.displayname, + icon: room.avatar.mxc.isEmpty + ? null + : await downloadAndSaveContent( + room.avatar, + width: 126, + height: 126, + ), + iconSource: IconSource.FilePath, + ); + + // Show notification + var androidPlatformChannelSpecifics = AndroidNotificationDetails( + 'fluffychat_push', + 'FluffyChat push channel', + 'Push notifications for FluffyChat', + style: AndroidNotificationStyle.Messaging, + styleInformation: MessagingStyleInformation( + person, + conversationTitle: title, + messages: [ + Message( + body, + event.time, + person, + ) + ], + ), + importance: Importance.Max, + priority: Priority.High, + ticker: 'New message in FluffyChat'); + var iOSPlatformChannelSpecifics = IOSNotificationDetails(); + var platformChannelSpecifics = NotificationDetails( + androidPlatformChannelSpecifics, iOSPlatformChannelSpecifics); + await _flutterLocalNotificationsPlugin.show( + 0, room.displayname, body, platformChannelSpecifics, + payload: roomId); + } catch (exception) { + print("[Push] Error while processing notification: " + + exception.toString()); + } + return null; }, + onResume: goToRoom, + // Currently fires unexpectetly... https://github.com/FirebaseExtended/flutterfire/issues/1060 + //onLaunch: goToRoom, ); + print("[Push] Firebase initialized"); + return; } void iOS_Permission() { @@ -174,25 +343,31 @@ class MatrixState extends State { }); } + void _initWithStore() async { + Future initLoginState = client.onLoginStateChanged.stream.first; + client.store = Store(client); + if (await initLoginState == LoginState.logged) { + await setupFirebase(); + } + } + @override void initState() { if (widget.client == null) { client = Client(widget.clientName, debug: false); if (!kIsWeb) { - client.store = Store(client); + _initWithStore(); } else { loadAccount(); } } else { client = widget.client; } - onSetupFirebase ??= client.onLoginStateChanged.stream.listen(setupFirebase); super.initState(); } @override void dispose() { - onSetupFirebase?.cancel(); super.dispose(); } diff --git a/lib/main.dart b/lib/main.dart index 5da3264..3ba2bbc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -47,8 +47,8 @@ class App extends StatelessWidget { ), ), home: Builder( - builder: (BuildContext context) => StreamBuilder( - stream: Matrix.of(context).client.onLoginStateChanged.stream, + builder: (BuildContext context) => FutureBuilder( + future: Matrix.of(context).client.onLoginStateChanged.stream.first, builder: (context, snapshot) { if (!snapshot.hasData) { return Scaffold( diff --git a/lib/views/chat.dart b/lib/views/chat.dart index f53ce99..e707b46 100644 --- a/lib/views/chat.dart +++ b/lib/views/chat.dart @@ -27,6 +27,8 @@ class _ChatState extends State { Timeline timeline; + MatrixState matrix; + String seenByText = ""; final ScrollController _scrollController = ScrollController(); @@ -81,6 +83,7 @@ class _ChatState extends State { @override void dispose() { timeline?.sub?.cancel(); + matrix.activeRoomId = ""; super.dispose(); } @@ -98,7 +101,7 @@ class _ChatState extends State { } File file = await FilePicker.getFile(); if (file == null) return; - await Matrix.of(context).tryRequestWithLoadingDialog( + await matrix.tryRequestWithLoadingDialog( room.sendFileEvent( MatrixFile(bytes: await file.readAsBytes(), path: file.path), ), @@ -115,7 +118,7 @@ class _ChatState extends State { maxWidth: 1600, maxHeight: 1600); if (file == null) return; - await Matrix.of(context).tryRequestWithLoadingDialog( + await matrix.tryRequestWithLoadingDialog( room.sendImageEvent( MatrixFile(bytes: await file.readAsBytes(), path: file.path), ), @@ -132,7 +135,7 @@ class _ChatState extends State { maxWidth: 1600, maxHeight: 1600); if (file == null) return; - await Matrix.of(context).tryRequestWithLoadingDialog( + await matrix.tryRequestWithLoadingDialog( room.sendImageEvent( MatrixFile(bytes: await file.readAsBytes(), path: file.path), ), @@ -141,16 +144,18 @@ class _ChatState extends State { @override Widget build(BuildContext context) { - Client client = Matrix.of(context).client; + matrix = Matrix.of(context); + Client client = matrix.client; room ??= client.getRoomById(widget.id); if (room == null) { return Center( child: Text("You are no longer participating in this chat"), ); } + matrix.activeRoomId = widget.id; if (room.membership == Membership.invite) { - Matrix.of(context).tryRequestWithLoadingDialog(room.join()); + matrix.tryRequestWithLoadingDialog(room.join()); } String typingText = ""; diff --git a/lib/views/login.dart b/lib/views/login.dart index 4b86478..b71905a 100644 --- a/lib/views/login.dart +++ b/lib/views/login.dart @@ -2,8 +2,11 @@ import 'dart:math'; import 'package:famedlysdk/famedlysdk.dart'; import 'package:fluffychat/components/matrix.dart'; +import 'package:fluffychat/utils/app_route.dart'; import 'package:flutter/material.dart'; +import 'chat_list.dart'; + const String defaultHomeserver = "https://matrix.org"; class LoginPage extends StatefulWidget { @@ -47,6 +50,7 @@ class _LoginPageState extends State { } try { + print("[Login] Check server..."); setState(() => loading = true); if (!await matrix.client.checkServer(homeserver)) { setState(() => serverError = "Homeserver is not compatible."); @@ -58,6 +62,7 @@ class _LoginPageState extends State { return setState(() => loading = false); } try { + print("[Login] Try to login..."); await matrix.client .login(usernameController.text, passwordController.text); } on MatrixException catch (exception) { @@ -67,8 +72,21 @@ class _LoginPageState extends State { setState(() => passwordError = exception.toString()); return setState(() => loading = false); } + try { + print("[Login] Setup Firebase..."); + await matrix.setupFirebase(); + } catch (exception) { + print("[Login] Failed to setup Firebase. Logout now..."); + await matrix.client.logout(); + matrix.clean(); + setState(() => passwordError = exception.toString()); + return setState(() => loading = false); + } + print("[Login] Store account and go to ChatListView"); await Matrix.of(context).saveAccount(); setState(() => loading = false); + await Navigator.of(context).pushAndRemoveUntil( + AppRoute.defaultRoute(context, ChatListView()), (r) => false); } @override diff --git a/lib/views/settings.dart b/lib/views/settings.dart index 8290614..eb3f28d 100644 --- a/lib/views/settings.dart +++ b/lib/views/settings.dart @@ -4,7 +4,9 @@ import 'package:famedlysdk/famedlysdk.dart'; import 'package:fluffychat/components/adaptive_page_layout.dart'; import 'package:fluffychat/components/content_banner.dart'; import 'package:fluffychat/components/matrix.dart'; +import 'package:fluffychat/utils/app_route.dart'; import 'package:fluffychat/views/chat_list.dart'; +import 'package:fluffychat/views/login.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; @@ -31,10 +33,11 @@ class _SettingsState extends State { Future profileFuture; dynamic profile; void logoutAction(BuildContext context) async { - await Navigator.of(context).popUntil((r) => r.isFirst); MatrixState matrix = Matrix.of(context); - await matrix.tryRequestWithErrorToast(matrix.client.logout()); + await matrix.tryRequestWithLoadingDialog(matrix.client.logout()); matrix.clean(); + await Navigator.of(context).pushAndRemoveUntil( + AppRoute.defaultRoute(context, LoginPage()), (r) => false); } void setDisplaynameAction(BuildContext context, String displayname) async { @@ -94,7 +97,9 @@ class _SettingsState extends State { Widget build(BuildContext context) { final Client client = Matrix.of(context).client; profileFuture ??= client.getProfileFromUserId(client.userID); - profileFuture.then((p) => setState(() => profile = p)); + profileFuture.then((p) { + if (mounted) setState(() => profile = p); + }); return Scaffold( appBar: AppBar( title: Text("Settings"), diff --git a/pubspec.lock b/pubspec.lock index b398bf0..0cde9d6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -82,8 +82,8 @@ packages: dependency: "direct main" description: path: "." - ref: "45744331ead079443e0dcb280a86867af2e21ccf" - resolved-ref: "45744331ead079443e0dcb280a86867af2e21ccf" + ref: "5a3f88e979fc85cb876dbfecffd8230c9698f864" + resolved-ref: "5a3f88e979fc85cb876dbfecffd8230c9698f864" url: "https://gitlab.com/famedly/famedlysdk.git" source: git version: "0.0.1" @@ -120,6 +120,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.7.4" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.1+2" flutter_speed_dial: dependency: "direct main" description: @@ -215,7 +222,7 @@ packages: source: hosted version: "1.6.4" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider url: "https://pub.dartlang.org" diff --git a/pubspec.yaml b/pubspec.yaml index b98bba7..0c94090 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,7 +27,7 @@ dependencies: famedlysdk: git: url: https://gitlab.com/famedly/famedlysdk.git - ref: 45744331ead079443e0dcb280a86867af2e21ccf + ref: 5a3f88e979fc85cb876dbfecffd8230c9698f864 localstorage: ^3.0.1+4 bubble: ^1.1.9+1 @@ -39,7 +39,9 @@ dependencies: sqflite: ^1.2.0 cached_network_image: ^2.0.0 firebase_messaging: ^6.0.9 + flutter_local_notifications: ^0.9.1+2 link_text: ^0.1.1 + path_provider: ^1.5.1 dev_dependencies: flutter_test: