fluffychat/lib/utils/background_push.dart

734 lines
23 KiB
Dart
Raw Normal View History

2021-02-07 17:18:38 +01:00
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:adaptive_page_layout/adaptive_page_layout.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:fcm_shared_isolate/fcm_shared_isolate.dart';
2021-04-03 13:09:20 +02:00
2021-02-07 17:18:38 +01:00
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';
2021-04-09 16:29:48 +02:00
import '../config/app_config.dart';
2021-02-07 17:18:38 +01:00
import '../config/setting_keys.dart';
import 'famedlysdk_store.dart';
import 'matrix_sdk_extensions.dart/fluffy_client.dart';
import 'matrix_sdk_extensions.dart/matrix_locals.dart';
2021-02-07 17:18:38 +01:00
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<AdaptivePageLayoutState> apl;
String _fcmToken;
LoginState _loginState;
L10n l10n;
Store _store;
Store get store => _store ??= Store();
Future<void> 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 = <String, Completer<void>>{};
DateTime lastReceivedPush;
BackgroundPush._(this.client) {
onLogin ??=
client.onLoginStateChanged.stream.listen(handleLoginStateChanged);
onRoomSync ??= client.onSync.stream
.where((s) => s.hasRoomUpdate)
.listen((s) => _onClearingPush(getFromServer: false));
2021-03-28 09:20:34 +02:00
_fcmSharedIsolate.setListeners(
onMessage: _onFcmMessage,
onNewToken: _newFcmToken,
);
2021-03-27 20:05:51 +01:00
if (Platform.isAndroid) {
UnifiedPush.initializeWithReceiver(
onNewEndpoint: _newUpEndpoint,
onRegistrationFailed: _upUnregistered,
onRegistrationRefused: _upUnregistered,
onUnregistered: _upUnregistered,
onMessage: _onUpMessage,
);
}
2021-02-07 17:18:38 +01:00
}
factory BackgroundPush.clientOnly(FluffyClient client) {
_instance ??= BackgroundPush._(client);
return _instance;
}
factory BackgroundPush(FluffyClient _client, BuildContext _context,
GlobalKey<AdaptivePageLayoutState> _apl) {
final instance = BackgroundPush.clientOnly(_client);
instance.context = _context;
instance.apl = _apl;
instance.fullInit();
return instance;
}
Future<void> fullInit() async {
await setupPush();
2021-02-07 17:18:38 +01:00
}
void handleLoginStateChanged(LoginState state) {
_loginState = state;
setupPush();
}
void _newFcmToken(String token) {
_fcmToken = token;
setupPush();
}
2021-03-27 20:05:51 +01:00
final _fcmSharedIsolate = FcmSharedIsolate();
2021-02-07 17:18:38 +01:00
StreamSubscription<LoginState> onLogin;
StreamSubscription<SyncUpdate> onRoomSync;
Future<void> setupPusher({
String gatewayUrl,
String token,
Set<String> oldTokens,
bool useDeviceSpecificAppId = false,
}) async {
2021-03-28 09:20:34 +02:00
if (PlatformInfos.isIOS) {
2021-04-21 14:19:54 +02:00
await _fcmSharedIsolate.requestPermission();
2021-03-28 09:20:34 +02:00
}
2021-02-07 17:18:38 +01:00
final clientName = PlatformInfos.clientName;
oldTokens ??= <String>{};
2021-05-20 13:59:55 +02:00
final pushers = await client.getPushers().catchError((e) {
2021-02-07 17:18:38 +01:00
Logs().w('[Push] Unable to request pushers', e);
return <Pusher>[];
});
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 {
2021-03-27 20:05:51 +01:00
Logs().i('Need to set new pusher');
2021-02-07 17:18:38 +01:00
oldTokens.add(token);
if (client.isLogged()) {
setNewPusher = true;
}
}
2021-03-27 20:05:51 +01:00
} else {
Logs().w('[Push] Missing required push credentials');
2021-02-07 17:18:38 +01:00
}
for (final pusher in pushers) {
if ((token != null &&
pusher.pushkey != token &&
deviceAppId == pusher.appId) ||
oldTokens.contains(pusher.pushkey)) {
pusher.kind = null;
try {
2021-05-20 13:59:55 +02:00
await client.postPusher(
2021-02-07 17:18:38 +01:00
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 {
2021-05-20 13:59:55 +02:00
await client.postPusher(
2021-02-07 17:18:38 +01:00
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);
}
}
}
bool _wentToRoomOnStartup = false;
2021-02-07 17:18:38 +01:00
Future<void> setupPush() async {
await setupLocalNotificationsPlugin();
2021-02-07 17:18:38 +01:00
if (_loginState != LoginState.logged ||
!PlatformInfos.isMobile ||
context == null) {
return;
}
if (!PlatformInfos.isIOS &&
(await UnifiedPush.getDistributors()).isNotEmpty) {
await setupUp();
} else {
await setupFirebase();
}
// ignore: unawaited_futures
_flutterLocalNotificationsPlugin
.getNotificationAppLaunchDetails()
.then((details) {
if (details == null ||
!details.didNotificationLaunchApp ||
_wentToRoomOnStartup ||
apl == null) {
return;
}
_wentToRoomOnStartup = true;
goToRoom(details.payload);
});
2021-02-07 17:18:38 +01:00
}
Future<void> _noFcmWarning() async {
if (context == null) {
return;
}
if (await store.getItemBool(SettingKeys.showNoGoogle, true)) {
await loadLocale();
2021-04-11 15:34:37 +02:00
apl.currentState.showSnackBar(SnackBar(
2021-04-03 13:09:20 +02:00
content: Text(
2021-05-23 07:12:44 +02:00
PlatformInfos.isAndroid
? l10n.noGoogleServicesWarning
: l10n.oopsPushError,
2021-04-03 13:09:20 +02:00
)));
2021-02-07 17:18:38 +01:00
if (null == await store.getItem(SettingKeys.showNoGoogle)) {
await store.setItemBool(SettingKeys.showNoGoogle, false);
}
}
}
Future<void> setupFirebase() async {
if (_fcmToken?.isEmpty ?? true) {
try {
2021-03-27 20:05:51 +01:00
_fcmToken = await _fcmSharedIsolate.getToken();
2021-02-07 17:18:38 +01:00
} catch (e, s) {
Logs().e('[Push] cannot get token', e, s);
await _noFcmWarning();
return;
}
}
await setupPusher(
gatewayUrl: AppConfig.pushNotificationsGatewayUrl,
token: _fcmToken,
);
}
Future<void> goToRoom(String roomId) async {
try {
Logs().v('[Push] Attempting to go to room $roomId...');
2021-02-07 17:18:38 +01:00
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<void> setupLocalNotificationsPlugin() async {
if (_notificationsPluginSetUp) {
return;
}
// initialise the plugin. app_icon needs to be a added as a drawable resource to the Android head project
2021-04-14 10:37:15 +02:00
final initializationSettingsAndroid =
2021-02-07 17:18:38 +01:00
AndroidInitializationSettings('notifications_icon');
2021-04-14 10:37:15 +02:00
final initializationSettingsIOS =
2021-02-07 17:18:38 +01:00
IOSInitializationSettings(onDidReceiveLocalNotification: (i, a, b, c) {
return null;
});
2021-04-14 10:37:15 +02:00
final initializationSettings = InitializationSettings(
2021-02-07 17:18:38 +01:00
android: initializationSettingsAndroid,
iOS: initializationSettingsIOS,
);
await _flutterLocalNotificationsPlugin.initialize(initializationSettings,
onSelectNotification: goToRoom);
_notificationsPluginSetUp = true;
}
Future<void> setupUp() async {
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));
}
// re-register the app without a dialog. This should be safe,
// and allows recovering from app deletion in gotify, for instance
Logs().i('[Push] UnifiedPush registration found, re-registering');
await UnifiedPush.registerApp();
2021-02-07 17:18:38 +01:00
}
Future<void> _onFcmMessage(Map<dynamic, dynamic> message) async {
2021-03-27 20:05:51 +01:00
Logs().v('[Push] Foreground message received');
2021-02-07 17:18:38 +01:00
Map<String, dynamic> data;
try {
data = Map<String, dynamic>.from(message['data'] ?? message);
await _onMessage(data);
} catch (e, s) {
Logs().e('[Push] Error while processing notification', e, s);
await _showDefaultNotification(data);
}
}
Future<void> _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;
2021-04-21 14:19:54 +02:00
final res =
json.decode(utf8.decode((await http.get(Uri.parse(url))).bodyBytes));
2021-02-07 17:18:38 +01:00
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 = <String>{};
try {
2021-03-27 20:05:51 +01:00
final fcmToken = await _fcmSharedIsolate.getToken();
2021-02-07 17:18:38 +01:00
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<void> _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<void> _onUpMessage(String message) async {
Map<String, dynamic> data;
try {
data = Map<String, dynamic>.from(json.decode(message)['notification']);
await _onMessage(data);
} catch (e, s) {
Logs().e('[Push] Error while processing notification', e, s);
await _showDefaultNotification(data);
}
}
Future<void> _onMessage(Map<String, dynamic> 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<String>('counts', '{}'))
: data.tryGet<Map<String, dynamic>>(
'counts', <String, dynamic>{})) as Map<String, dynamic>)
.tryGet<int>('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<bool> 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<String, int> idMap;
Future<void> _loadIdMap() async {
idMap ??= Map<String, int>.from(json.decode(
(await store.getItem(SettingKeys.notificationCurrentIds)) ?? '{}'));
}
Future<int> 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<void> _onClearingPush({bool getFromServer = true}) async {
if (_clearingPushLock) {
return;
}
try {
_clearingPushLock = true;
Iterable<String> 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...');
2021-05-20 13:59:55 +02:00
final notifications = await client.getNotifications(limit: 20);
2021-02-07 17:18:38 +01:00
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<void> _showNotification(String roomId, String eventId) async {
await setupLocalNotificationsPlugin();
final room = client.getRoomById(roomId);
if (room == null) {
throw 'Room not found';
}
2021-04-08 07:38:14 +02:00
await room.postLoad();
final event = await client.database.getEventById(client.id, eventId, room);
2021-02-07 17:18:38 +01:00
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
2021-04-14 10:37:15 +02:00
final androidPlatformChannelSpecifics = _getAndroidNotificationDetails(
2021-02-07 17:18:38 +01:00
styleInformation: MessagingStyleInformation(
person,
conversationTitle: title,
messages: [
Message(
body,
event?.originServerTs ?? DateTime.now(),
person,
)
],
),
ticker: l10n.newMessageInFluffyChat,
);
2021-04-14 10:37:15 +02:00
final iOSPlatformChannelSpecifics = IOSNotificationDetails();
final platformChannelSpecifics = NotificationDetails(
2021-02-07 17:18:38 +01:00
android: androidPlatformChannelSpecifics,
iOS: iOSPlatformChannelSpecifics,
);
await _flutterLocalNotificationsPlugin.show(
await mapRoomIdToInt(room.id),
room.getLocalizedDisplayname(MatrixLocals(l10n)),
body,
platformChannelSpecifics,
payload: roomId,
);
}
Future<dynamic> _showDefaultNotification(Map<String, dynamic> data) async {
try {
await setupLocalNotificationsPlugin();
await loadLocale();
2021-04-14 10:37:15 +02:00
final String eventId = data['event_id'];
final String roomId = data['room_id'];
2021-02-07 17:18:38 +01:00
final unread = ((data['counts'] is String
? json.decode(data.tryGet<String>('counts', '{}'))
: data.tryGet<Map<String, dynamic>>(
'counts', <String, dynamic>{})) as Map<String, dynamic>)
.tryGet<int>('unread', 1);
if (unread == 0 || roomId == null || eventId == null) {
await _onClearingPush();
return;
}
// Display notification
2021-04-14 10:37:15 +02:00
final androidPlatformChannelSpecifics = _getAndroidNotificationDetails();
final iOSPlatformChannelSpecifics = IOSNotificationDetails();
final platformChannelSpecifics = NotificationDetails(
2021-02-07 17:18:38 +01:00
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<String> 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' : '';
2021-04-14 10:37:15 +02:00
final file =
2021-02-07 17:18:38 +01:00
File('$tempDirectory/${prefix}_${content.toString().split("/").last}');
if (!file.existsSync()) {
final url = thumbnail
? content.getThumbnail(client, width: width, height: height)
: content.getDownloadLink(client);
2021-04-21 14:19:54 +02:00
final request = await HttpClient().getUrl(url);
2021-04-14 10:37:15 +02:00
final response = await request.close();
2021-02-07 17:18:38 +01:00
if (response.statusCode >= 300) {
// we are not in the 2xx range
return null;
}
2021-04-14 10:37:15 +02:00
final bytes = await consolidateHttpClientResponseBytes(response);
2021-02-07 17:18:38 +01:00
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,
);
}
}