fluffychat/lib/utils/background_push.dart

723 lines
23 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:http/http.dart' as http;
import 'package:matrix/matrix.dart';
import 'package:unifiedpush/unifiedpush.dart' hide Message;
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/client_stories_extension.dart';
import '../config/app_config.dart';
import '../config/setting_keys.dart';
import 'famedlysdk_store.dart';
import 'matrix_sdk_extensions.dart/matrix_locals.dart';
import 'platform_infos.dart';
//import 'package:fcm_shared_isolate/fcm_shared_isolate.dart';
class NoTokenException implements Exception {
String get cause => 'Cannot get firebase token';
}
class BackgroundPush {
static BackgroundPush? _instance;
final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
Client client;
BuildContext? context;
GlobalKey<VRouterState>? router;
String? _fcmToken;
void Function(String errorMsg, {Uri? link})? onFcmError;
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));
}
final pendingTests = <String, Completer<void>>{};
DateTime? lastReceivedPush;
bool upAction = false;
BackgroundPush._(this.client, {this.onFcmError}) {
onLogin ??=
client.onLoginStateChanged.stream.listen(handleLoginStateChanged);
onRoomSync ??= client.onSync.stream
.where((s) => s.hasRoomUpdate)
.listen((s) => _onClearingPush(getFromServer: false));
_fcmSharedIsolate?.setListeners(
onMessage: _onFcmMessage,
onNewToken: _newFcmToken,
);
if (Platform.isAndroid) {
UnifiedPush.initializeWithReceiver(
onNewEndpoint: _newUpEndpoint,
onRegistrationFailed: _upUnregistered,
onRegistrationRefused: _upUnregistered,
onUnregistered: _upUnregistered,
onMessage: _onUpMessage,
);
}
}
factory BackgroundPush.clientOnly(Client client) {
_instance ??= BackgroundPush._(client);
return _instance!;
}
factory BackgroundPush(
Client _client, BuildContext _context, GlobalKey<VRouterState>? router,
{final void Function(String errorMsg, {Uri? link})? onFcmError}) {
final instance = BackgroundPush.clientOnly(_client);
instance.context = _context;
// ignore: prefer_initializing_formals
instance.router = router;
// ignore: prefer_initializing_formals
instance.onFcmError = onFcmError;
instance.fullInit();
return instance;
}
Future<void> fullInit() => setupPush();
void handleLoginStateChanged(_) => setupPush();
void _newFcmToken(String token) {
_fcmToken = token;
setupPush();
}
final dynamic _fcmSharedIsolate = null; //FcmSharedIsolate();
StreamSubscription<LoginState>? onLogin;
StreamSubscription<SyncUpdate>? onRoomSync;
Future<void> setupPusher({
String? gatewayUrl,
String? token,
Set<String?>? oldTokens,
bool useDeviceSpecificAppId = false,
}) async {
if (PlatformInfos.isIOS) {
await _fcmSharedIsolate?.requestPermission();
}
final clientName = PlatformInfos.clientName;
oldTokens ??= <String>{};
final pushers = await (client.getPushers().catchError((e) {
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) {
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 {
Logs().i('Need to set new pusher');
oldTokens.add(token);
if (client.isLogged()) {
setNewPusher = true;
}
}
} else {
Logs().w('[Push] Missing required push credentials');
}
for (final pusher in pushers) {
if ((token != null &&
pusher.pushkey != token &&
deviceAppId == pusher.appId) ||
oldTokens.contains(pusher.pushkey)) {
try {
await client.deletePusher(pusher);
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.postPusher(
Pusher(
pushkey: token!,
appId: thisAppId,
appDisplayName: clientName,
deviceDisplayName: client.deviceName!,
lang: 'en',
data: 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;
Future<void> setupPush() async {
Logs().d("SetupPush");
await setupLocalNotificationsPlugin();
if (client.loginState != LoginState.loggedIn ||
!PlatformInfos.isMobile ||
context == null) {
return;
}
// Do not setup unifiedpush if this has been initialized by
// an unifiedpush action
if (upAction) {
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 ||
router == null) {
return;
}
_wentToRoomOnStartup = true;
goToRoom(details.payload);
});
}
Future<void> _noFcmWarning() async {
if (context == null) {
return;
}
if (await store.getItemBool(SettingKeys.showNoGoogle, true)) {
await loadLocale();
if (PlatformInfos.isAndroid) {
onFcmError?.call(
l10n!.noGoogleServicesWarning,
link: Uri.parse(
AppConfig.enablePushTutorial,
),
);
}
onFcmError?.call(l10n!.oopsPushError);
if (null == await store.getItem(SettingKeys.showNoGoogle)) {
await store.setItemBool(SettingKeys.showNoGoogle, false);
}
}
}
Future<void> setupFirebase() async {
Logs().v('Setup firebase');
if (_fcmToken?.isEmpty ?? true) {
try {
_fcmToken = await _fcmSharedIsolate?.getToken();
if (_fcmToken == null) throw ('PushToken is null');
} catch (e, s) {
Logs().w('[Push] cannot get token', e, e is String ? null : 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...');
if (router == null || roomId == null) {
return;
}
await client.roomsLoading;
await client.accountDataLoading;
final isStory = client
.getRoomById(roomId)
?.getState(EventTypes.RoomCreate)
?.content
.tryGet<String>('type') ==
ClientStoriesExtension.storiesRoomType;
router!.currentState!.toSegments([isStory ? 'stories' : '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
const initializationSettingsAndroid =
AndroidInitializationSettings('notifications_icon');
final initializationSettingsIOS = IOSInitializationSettings(
onDidReceiveLocalNotification: (i, a, b, c) async => null,
);
final initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsIOS,
);
await _flutterLocalNotificationsPlugin.initialize(initializationSettings,
onSelectNotification: goToRoom);
_notificationsPluginSetUp = true;
}
Future<void> setupUp() async {
await UnifiedPush.registerAppWithDialog();
}
Future<void> _onFcmMessage(Map<dynamic, dynamic> message) async {
Logs().v('[Push] Foreground message received');
final data = Map<String, dynamic>.from(message['data'] ?? message);
try {
await _onMessage(data);
} catch (e, s) {
Logs().e('[Push] Error while processing notification', e, s);
await _showDefaultNotification(data);
}
}
Future<void> _newUpEndpoint(String newEndpoint) async {
upAction = true;
if (newEndpoint.isEmpty) {
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(Uri.parse(url))).bodyBytes));
if (res['gateway'] == 'matrix' ||
(res['unifiedpush'] is Map &&
res['unifiedpush']['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 {
final fcmToken = await _fcmSharedIsolate?.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<void> _upUnregistered() async {
upAction = true;
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 {
upAction = true;
final data =
Map<String, dynamic>.from(json.decode(message)['notification']);
try {
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;
}
// For legacy reasons the counts map could be a String encoded JSON:
final countsMap =
data.tryGetMap<String, dynamic>('counts', TryGet.silent) ??
(jsonDecode(data.tryGet<String>('counts') ?? '{}')
as Map<String, dynamic>);
final unread = countsMap.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(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.
late 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;
}
var nCurrentInt = 0;
while (idMap.values.contains(currentInt)) {
nCurrentInt++;
}
idMap[roomId] = nCurrentInt;
await store.setItem(SettingKeys.notificationCurrentIds, json.encode(idMap));
return nCurrentInt;
}
bool _clearingPushLock = false;
Future<void> _onClearingPush({bool getFromServer = true}) async {
if (_clearingPushLock) {
return;
}
try {
_clearingPushLock = true;
late 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...');
final notifications = await client.getNotifications(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) {
final id = idMap[roomId];
if (id != null) {
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';
}
await room.postLoad();
final event = await client.database!.getEventById(eventId, room);
final activeRoomId = router!.currentState!.pathParameters['roomid'];
if (((activeRoomId?.isNotEmpty ?? false) &&
activeRoomId == room.id &&
client.syncPresence == null) ||
(event != null && (room.notificationCount == 0))) {
return;
}
// load the locale
await loadLocale();
// Calculate title
final title = l10n!.unreadMessages(room.notificationCount);
// Calculate the body
final body = event!.getLocalizedBody(
MatrixLocals(L10n.of(context!)!),
withSenderNamePrefix: !room.isDirectChat,
plaintextBody: true,
hideReply: true,
hideEdit: true,
);
// The person object for the android message style notification
final avatar = room.avatar == null
? null
: await DefaultCacheManager().getSingleFile(
event.room.avatar!
.getThumbnail(
client,
width: 126,
height: 126,
)
.toString(),
);
final person = Person(
name: room.getLocalizedDisplayname(MatrixLocals(l10n!)),
icon: avatar == null ? null : BitmapFilePathAndroidIcon(avatar.path),
);
// Show notification
final androidPlatformChannelSpecifics = _getAndroidNotificationDetails(
styleInformation: MessagingStyleInformation(
person,
conversationTitle: title,
messages: [
Message(
body,
event.originServerTs,
person,
)
],
),
ticker: l10n!.newMessageInFluffyChat,
);
const iOSPlatformChannelSpecifics = IOSNotificationDetails();
final platformChannelSpecifics = NotificationDetails(
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();
final String? eventId = data['event_id'];
final String? roomId = data['room_id'];
// For legacy reasons the counts map could be a String encoded JSON:
final countsMap = data.tryGetMap<String, dynamic>('counts') ??
(jsonDecode(data.tryGet<String>('counts') ?? '{}')
as Map<String, dynamic>);
final unread = countsMap.tryGet<int>('unread') ?? 1;
if (unread == 0 || roomId == null || eventId == null) {
await _onClearingPush();
return;
}
// Display notification
final androidPlatformChannelSpecifics = _getAndroidNotificationDetails();
const iOSPlatformChannelSpecifics = IOSNotificationDetails();
final platformChannelSpecifics = NotificationDetails(
android: androidPlatformChannelSpecifics,
iOS: iOSPlatformChannelSpecifics,
);
final title = l10n!.unreadChats(unread);
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);
}
}
AndroidNotificationDetails _getAndroidNotificationDetails(
{MessagingStyleInformation? styleInformation, String? ticker}) {
final color = (context != null ? Theme.of(context!).primaryColor : null) ??
const Color(0xFF5625BA);
return AndroidNotificationDetails(
AppConfig.pushNotificationsChannelId,
AppConfig.pushNotificationsChannelName,
AppConfig.pushNotificationsChannelDescription,
styleInformation: styleInformation,
importance: Importance.max,
priority: Priority.high,
ticker: ticker,
color: color,
);
}
}