diff --git a/README.md b/README.md
index 6c2074ce..0bca8cbe 100644
--- a/README.md
+++ b/README.md
@@ -146,6 +146,45 @@ Example B:
3. For testing just run a regular build without extras
+# Android push notifications without FCM
+Fluffychat has the ability to receive push notifications on android without FCM via the
+[UnifiedPush](https://github.com/UnifiedPush) project, e.g. using gotify as push backend. As the project is still pretty new
+there might still be some bugs, overall it seems to be working, though.
+
+While UnifiedPush also supports p2p push via [NoProvider2Push](https://github.com/NoProvider2Push/android)
+here the gotify setup will be outlined. Adapt re-write proxies accordingly, if you want to use a different push provider.
+
+For self-hosted push with gotify you have to install and configure [gotify-server](https://github.com/gotify/server)
+with [UnifiedPush](https://github.com/UnifiedPush/contrib/blob/main/providers/gotify.md) support.
+
+Next, you add the `repo.unifiedpush.org` repository to fdroid and install the gotify client via it. Log into your gotify account and push notifications should work!
+
+## Matrix-specific re-write proxy
+Until [MSC2970](https://github.com/matrix-org/matrix-doc/pull/2970) is figured out we unfortunately
+need another simple re-write proxy. By default the one at https://matrix.gateway.unifiedpush.org
+is used, however you can easily self-host it. For that, add to your nginx config on the same domain you serve gotify the following:
+```
+resolver 8.8.8.8;
+
+location /_matrix/push/v1/notify {
+ set $target '';
+ if ($request_method = GET ) {
+ return 200 '{"gateway":"matrix"}';
+ }
+ access_by_lua_block {
+ local cjson = require("cjson")
+ ngx.req.read_body()
+ local body = ngx.req.get_body_data()
+ local parsedBody = cjson.decode(body)
+ ngx.var.target = parsedBody["notification"]["devices"][1]["pushkey"]
+ ngx.req.set_body_data(body)
+ }
+ proxy_set_header Content-Type application/json;
+ proxy_set_header Host $host;
+ proxy_pass $target;
+}
+```
+
# Special thanks to
* Fabiyamada is a graphics designer from Brasil and has made the fluffychat logo and the banner. Big thanks for her great designs.
@@ -158,4 +197,4 @@ Example B:
* Also thanks to all translators and testers! With your help, fluffychat is now available in more than 12 languages.
-* Noto Emoji Font for the awesome emojis.
\ No newline at end of file
+* Noto Emoji Font for the awesome emojis.
diff --git a/android/app/src/main/kotlin/chat/fluffy/fluffychat/Application.kt b/android/app/src/main/kotlin/chat/fluffy/fluffychat/Application.kt
index 2df7fe77..249803ab 100644
--- a/android/app/src/main/kotlin/chat/fluffy/fluffychat/Application.kt
+++ b/android/app/src/main/kotlin/chat/fluffy/fluffychat/Application.kt
@@ -28,4 +28,4 @@ class Application : FlutterApplication(), PluginRegistrantCallback {
FlutterSecureStoragePlugin.registerWith(registry.registrarFor("com.it_nomads.fluttersecurestorage"));
}
}
-}
\ No newline at end of file
+}
diff --git a/android/build.gradle b/android/build.gradle
index 70b3637d..abd75862 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -6,7 +6,7 @@ buildscript {
}
dependencies {
- classpath 'com.android.tools.build:gradle:3.5.0'
+ classpath 'com.android.tools.build:gradle:4.1.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.2'
}
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index 296b146b..3c46198f 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,5 @@
-#Fri Jun 23 08:50:38 CEST 2017
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip
diff --git a/android/settings_aar.gradle b/android/settings_aar.gradle
new file mode 100644
index 00000000..e7b4def4
--- /dev/null
+++ b/android/settings_aar.gradle
@@ -0,0 +1 @@
+include ':app'
diff --git a/lib/components/matrix.dart b/lib/components/matrix.dart
index 62786b96..45e63dd6 100644
--- a/lib/components/matrix.dart
+++ b/lib/components/matrix.dart
@@ -340,10 +340,9 @@ class MatrixState extends State {
widget.apl.currentState.pushNamedAndRemoveAllOthers('/');
if (loginState == LoginState.logged) {
FirebaseController.context = context;
- FirebaseController.setupFirebase(
- this,
- clientName,
- ).catchError(SentryController.captureException);
+ FirebaseController.matrix = this;
+ FirebaseController.setupFirebase(clientName)
+ .catchError(SentryController.captureException);
}
}
});
diff --git a/lib/config/setting_keys.dart b/lib/config/setting_keys.dart
index 27abb602..ca067f0c 100644
--- a/lib/config/setting_keys.dart
+++ b/lib/config/setting_keys.dart
@@ -12,4 +12,7 @@ abstract class SettingKeys {
static const String showNoPid = 'chat.fluffy.show_no_pid';
static const String databasePassword = 'database-password';
static const String appLockKey = 'chat.fluffy.app_lock';
+ static const String unifiedPushRegistered =
+ 'chat.fluffy.unifiedpush.registered';
+ static const String unifiedPushEndpoint = 'chat.fluffy.unifiedpush.endpoint';
}
diff --git a/lib/utils/famedlysdk_store.dart b/lib/utils/famedlysdk_store.dart
index 0ce242d8..e59c621e 100644
--- a/lib/utils/famedlysdk_store.dart
+++ b/lib/utils/famedlysdk_store.dart
@@ -92,6 +92,10 @@ class Store {
return await secureStorage.write(key: key, value: value);
}
+ Future setItemBool(String key, bool value) async {
+ await setItem(key, value.toString());
+ }
+
Future deleteItem(String key) async {
if (!PlatformInfos.isMobile) {
await _setupLocalStorage();
diff --git a/lib/utils/firebase_controller.dart b/lib/utils/firebase_controller.dart
index 347fd751..e882db80 100644
--- a/lib/utils/firebase_controller.dart
+++ b/lib/utils/firebase_controller.dart
@@ -13,6 +13,8 @@ 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';
@@ -26,91 +28,22 @@ abstract class FirebaseController {
static BuildContext context;
static MatrixState matrix;
- static Future setupFirebase(
- MatrixState matrix, String clientName) async {
- FirebaseController.matrix = matrix;
+ static Future setupFirebase(String clientName) async {
if (!PlatformInfos.isMobile) return;
- final client = matrix.client;
if (Platform.isIOS) iOS_Permission();
- String token;
- try {
- token = await _firebaseMessaging.getToken();
- if (token?.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);
- final storeItem = await matrix.store.getItem(SettingKeys.showNoGoogle);
- final configOptionMissing = storeItem == null || storeItem.isEmpty;
- if (configOptionMissing || (!configOptionMissing && storeItem == '1')) {
- await FlushbarHelper.createError(
- message: L10n.of(context).noGoogleServicesWarning,
- duration: Duration(seconds: 15),
- ).show(context);
- if (configOptionMissing) {
- await matrix.store.setItem(SettingKeys.showNoGoogle, '0');
- }
- }
- return;
- }
- final pushers = await client.requestPushers().catchError((e) {
- Logs().w('[Push] Unable to request pushers', e);
- return [];
- });
- 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() ==
- AppConfig.pushNotificationsGatewayUrl &&
- currentPushers.first.data.format ==
- AppConfig.pushNotificationsPusherFormat) {
- Logs().i('[Push] Pusher already set');
- } else {
- if (currentPushers.isNotEmpty) {
- for (final currentPusher in currentPushers) {
- currentPusher.pushkey = token;
- currentPusher.kind = null;
- await client.setPusher(
- currentPusher,
- append: true,
- );
- Logs().i('[Push] Remove legacy pusher for this device');
- }
- }
- await client
- .setPusher(
- Pusher(
- token,
- AppConfig.pushNotificationsAppId,
- clientName,
- client.deviceName,
- 'en',
- PusherData(
- url: Uri.parse(AppConfig.pushNotificationsGatewayUrl),
- format: AppConfig.pushNotificationsPusherFormat,
- ),
- kind: 'http',
- ),
- append: false,
- )
- .catchError((e, s) {
- Logs().e('[Push] Unable to set pushers', e, s);
- return [];
- });
- }
-
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)['room_id'];
+ roomId = (message['data'] ??
+ message['notification'] ??
+ message)['room_id'];
}
if (roomId?.isEmpty ?? true) throw ('Bad roomId');
await matrix.widget.apl.currentState
@@ -136,162 +69,371 @@ abstract class FirebaseController {
await _flutterLocalNotificationsPlugin.initialize(initializationSettings,
onSelectNotification: goToRoom);
+ // ignore: unawaited_futures
+ _flutterLocalNotificationsPlugin
+ .getNotificationAppLaunchDetails()
+ .then((details) {
+ if (details == null || !details.didNotificationLaunchApp) {
+ return;
+ }
+ goToRoom(details.payload);
+ });
+
+ if ((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: _onMessage,
- onBackgroundMessage: _onMessage,
+ onMessage: _onFcmMessage,
+ onBackgroundMessage: _onFcmMessage,
onResume: goToRoom,
onLaunch: goToRoom,
);
Logs().i('[Push] Firebase initialized');
- return;
}
- static Future _onMessage(Map message) async {
- try {
- final data = message['data'] ?? message;
- final String roomId = data['room_id'];
- final String eventId = data['event_id'];
- final int unread = json.decode(data['counts'])['unread'];
- if ((roomId?.isEmpty ?? true) ||
- (eventId?.isEmpty ?? true) ||
- unread == 0) {
- await _flutterLocalNotificationsPlugin.cancelAll();
- return null;
+ 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;
}
- if (context != null && matrix.activeRoomId == roomId) {
- Logs().i('[Push] New clearing push');
- return null;
+ }
+ 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);
+ }
}
- 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
- Client client;
- var tempClient = false;
+ }
+ if (setNewPusher) {
try {
- client = matrix.client;
- } catch (_) {
- client = null;
- }
- 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: 5),
- );
- }
-
- // Get the room
- var room = client.getRoomById(roomId);
- if (room == null) {
- Logs().i('[Push] Wait for the room');
- await client.onRoomUpdate.stream
- .where((u) => u.id == roomId)
- .first
- .timeout(Duration(seconds: 5));
- Logs().i('[Push] Room found');
- room = client.getRoomById(roomId);
- if (room == null) return 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 client.onEvent.stream
- .where((u) => u.content['event_id'] == eventId)
- .first
- .timeout(Duration(seconds: 5));
- Logs().i('[Push] Event found');
- event = Event.fromJson(eventUpdate.content, room);
- if (room == null) return 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 = AndroidNotificationDetails(
- AppConfig.pushNotificationsChannelId,
- AppConfig.pushNotificationsChannelName,
- AppConfig.pushNotificationsChannelDescription,
- styleInformation: MessagingStyleInformation(
- person,
- conversationTitle: title,
- messages: [
- Message(
- body,
- event.originServerTs,
- person,
- )
- ],
+ await client.setPusher(
+ Pusher(
+ token,
+ AppConfig.pushNotificationsAppId,
+ clientName,
+ client.deviceName,
+ 'en',
+ PusherData(
+ url: Uri.parse(gatewayUrl),
+ format: AppConfig.pushNotificationsPusherFormat,
+ ),
+ kind: 'http',
),
- importance: Importance.max,
- priority: Priority.high,
- 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');
+ 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(message);
+ 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