fluffychat/lib/utils/background_push.dart

484 lines
15 KiB
Dart
Raw Normal View History

2021-02-07 16:18:38 +00: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';
2022-04-05 05:49:28 +00:00
import 'dart:typed_data';
2021-02-07 16:18:38 +00:00
import 'dart:ui';
import 'package:flutter/material.dart';
2021-10-26 16:50:34 +00:00
2021-02-07 16:18:38 +00:00
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
2021-10-26 16:50:34 +00:00
import 'package:http/http.dart' as http;
import 'package:matrix/matrix.dart';
2022-04-05 05:49:28 +00:00
import 'package:unifiedpush/unifiedpush.dart';
2021-05-23 11:11:55 +00:00
import 'package:vrouter/vrouter.dart';
2021-10-26 16:50:34 +00:00
2022-12-30 16:54:01 +00:00
import 'package:fluffychat/utils/matrix_sdk_extensions/client_stories_extension.dart';
2022-04-14 05:34:55 +00:00
import 'package:fluffychat/utils/push_helper.dart';
2021-04-09 14:29:48 +00:00
import '../config/app_config.dart';
2021-02-07 16:18:38 +00:00
import '../config/setting_keys.dart';
import 'famedlysdk_store.dart';
2021-10-26 16:50:34 +00:00
import 'platform_infos.dart';
//import 'package:fcm_shared_isolate/fcm_shared_isolate.dart';
2021-02-07 16:18:38 +00:00
class NoTokenException implements Exception {
String get cause => 'Cannot get firebase token';
}
class BackgroundPush {
2022-01-29 11:35:03 +00:00
static BackgroundPush? _instance;
2021-02-07 16:18:38 +00:00
final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
Client client;
2022-01-29 11:35:03 +00:00
BuildContext? context;
GlobalKey<VRouterState>? router;
String? _fcmToken;
void Function(String errorMsg, {Uri? link})? onFcmError;
L10n? l10n;
Store? _store;
2021-02-07 16:18:38 +00:00
Store get store => _store ??= Store();
Future<void> loadLocale() async {
// inspired by _lookupL10n in .dart_tool/flutter_gen/gen_l10n/l10n.dart
2022-01-29 11:35:03 +00:00
l10n ??= (context != null ? L10n.of(context!) : null) ??
(await L10n.delegate.load(window.locale));
2021-02-07 16:18:38 +00:00
}
final pendingTests = <String, Completer<void>>{};
2022-04-14 05:34:55 +00:00
final dynamic firebase = null; //FcmSharedIsolate();
2022-01-29 11:35:03 +00:00
DateTime? lastReceivedPush;
2021-02-07 16:18:38 +00:00
bool upAction = false;
2022-05-12 09:25:34 +00:00
BackgroundPush._(this.client) {
2021-02-07 16:18:38 +00:00
onRoomSync ??= client.onSync.stream
.where((s) => s.hasRoomUpdate)
.listen((s) => _onClearingPush(getFromServer: false));
2022-04-14 05:34:55 +00:00
firebase?.setListeners(
onMessage: (message) => pushHelper(
PushNotification.fromJson(
Map<String, dynamic>.from(message['data'] ?? message)),
client: client,
l10n: l10n,
activeRoomId: router?.currentState?.pathParameters['roomid'],
onSelectNotification: goToRoom,
),
2021-03-28 07:20:34 +00:00
);
2021-03-27 19:05:51 +00:00
if (Platform.isAndroid) {
2022-04-05 05:49:28 +00:00
UnifiedPush.initialize(
2021-03-27 19:05:51 +00:00
onNewEndpoint: _newUpEndpoint,
onRegistrationFailed: _upUnregistered,
onUnregistered: _upUnregistered,
onMessage: _onUpMessage,
);
}
2021-02-07 16:18:38 +00:00
}
factory BackgroundPush.clientOnly(Client client) {
2021-02-07 16:18:38 +00:00
_instance ??= BackgroundPush._(client);
2022-01-29 11:35:03 +00:00
return _instance!;
2021-02-07 16:18:38 +00:00
}
factory BackgroundPush(
2022-08-14 14:59:21 +00:00
Client client,
BuildContext context,
GlobalKey<VRouterState>? router, {
final void Function(String errorMsg, {Uri? link})? onFcmError,
}) {
final instance = BackgroundPush.clientOnly(client);
instance.context = context;
2021-10-14 16:09:30 +00:00
// ignore: prefer_initializing_formals
2021-05-23 11:11:55 +00:00
instance.router = router;
2021-10-14 16:09:30 +00:00
// ignore: prefer_initializing_formals
2021-05-28 18:32:52 +00:00
instance.onFcmError = onFcmError;
2021-02-07 16:18:38 +00:00
return instance;
}
2022-04-14 05:34:55 +00:00
StreamSubscription<SyncUpdate>? onRoomSync;
2021-02-07 16:18:38 +00:00
Future<void> setupPusher({
2022-01-29 11:35:03 +00:00
String? gatewayUrl,
String? token,
Set<String?>? oldTokens,
2021-02-07 16:18:38 +00:00
bool useDeviceSpecificAppId = false,
}) async {
2021-03-28 07:20:34 +00:00
if (PlatformInfos.isIOS) {
2022-04-14 05:34:55 +00:00
await firebase?.requestPermission();
2021-03-28 07:20:34 +00:00
}
2021-02-07 16:18:38 +00:00
final clientName = PlatformInfos.clientName;
oldTokens ??= <String>{};
2022-01-29 11:35:03 +00:00
final pushers = await (client.getPushers().catchError((e) {
Logs().w('[Push] Unable to request pushers', e);
return <Pusher>[];
})) ??
[];
2021-02-07 16:18:38 +00:00
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;
2022-01-29 11:35:03 +00:00
if (gatewayUrl != null && token != null) {
2021-02-07 16:18:38 +00:00
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 19:05:51 +00:00
Logs().i('Need to set new pusher');
2021-02-07 16:18:38 +00:00
oldTokens.add(token);
if (client.isLogged()) {
setNewPusher = true;
}
}
2021-03-27 19:05:51 +00:00
} else {
Logs().w('[Push] Missing required push credentials');
2021-02-07 16:18:38 +00:00
}
for (final pusher in pushers) {
if ((token != null &&
pusher.pushkey != token &&
deviceAppId == pusher.appId) ||
oldTokens.contains(pusher.pushkey)) {
try {
2022-01-29 11:35:03 +00:00
await client.deletePusher(pusher);
2021-02-07 16:18:38 +00:00
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 11:59:55 +00:00
await client.postPusher(
2021-02-07 16:18:38 +00:00
Pusher(
2022-01-29 11:35:03 +00:00
pushkey: token!,
appId: thisAppId,
appDisplayName: clientName,
2022-01-29 11:35:03 +00:00
deviceDisplayName: client.deviceName!,
lang: 'en',
data: PusherData(
2022-01-29 11:35:03 +00:00
url: Uri.parse(gatewayUrl!),
2021-02-07 16:18:38 +00:00
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 16:18:38 +00:00
Future<void> setupPush() async {
Logs().d("SetupPush");
2022-07-09 08:18:53 +00:00
if (client.onLoginStateChanged.value != LoginState.loggedIn ||
2021-02-07 16:18:38 +00:00
!PlatformInfos.isMobile ||
context == null) {
return;
}
// Do not setup unifiedpush if this has been initialized by
// an unifiedpush action
if (upAction) {
return;
}
2021-02-07 16:18:38 +00:00
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 ||
2021-05-23 11:11:55 +00:00
router == null) {
return;
}
_wentToRoomOnStartup = true;
goToRoom(details.notificationResponse);
});
2021-02-07 16:18:38 +00:00
}
Future<void> _noFcmWarning() async {
if (context == null) {
return;
}
if (await store.getItemBool(SettingKeys.showNoGoogle, true) == true) {
2022-11-04 12:49:23 +00:00
return;
}
await loadLocale();
WidgetsBinding.instance.addPostFrameCallback((_) {
2021-09-24 09:42:56 +00:00
if (PlatformInfos.isAndroid) {
onFcmError?.call(
2022-01-29 11:35:03 +00:00
l10n!.noGoogleServicesWarning,
2021-09-24 09:42:56 +00:00
link: Uri.parse(
AppConfig.enablePushTutorial,
),
);
2022-11-04 12:49:23 +00:00
return;
2021-09-24 09:42:56 +00:00
}
2022-01-29 11:35:03 +00:00
onFcmError?.call(l10n!.oopsPushError);
2022-11-04 12:49:23 +00:00
});
2021-02-07 16:18:38 +00:00
}
Future<void> setupFirebase() async {
2021-05-28 18:32:52 +00:00
Logs().v('Setup firebase');
2021-02-07 16:18:38 +00:00
if (_fcmToken?.isEmpty ?? true) {
try {
2022-04-14 05:34:55 +00:00
_fcmToken = await firebase?.getToken();
2021-11-21 08:07:33 +00:00
if (_fcmToken == null) throw ('PushToken is null');
2021-02-07 16:18:38 +00:00
} catch (e, s) {
2021-11-21 08:07:33 +00:00
Logs().w('[Push] cannot get token', e, e is String ? null : s);
2021-02-07 16:18:38 +00:00
await _noFcmWarning();
return;
}
}
await setupPusher(
gatewayUrl: AppConfig.pushNotificationsGatewayUrl,
token: _fcmToken,
);
}
Future<void> goToRoom(NotificationResponse? response) async {
2021-02-07 16:18:38 +00:00
try {
final roomId = response?.payload;
Logs().v('[Push] Attempting to go to room $roomId...');
if (router == null || roomId == null) {
2021-02-07 16:18:38 +00:00
return;
}
2022-01-29 11:35:03 +00:00
await client.roomsLoading;
await client.accountDataLoading;
2021-12-25 13:42:48 +00:00
final isStory = client
.getRoomById(roomId)
2021-12-25 13:42:48 +00:00
?.getState(EventTypes.RoomCreate)
?.content
2022-01-29 11:35:03 +00:00
.tryGet<String>('type') ==
2021-12-25 13:42:48 +00:00
ClientStoriesExtension.storiesRoomType;
2022-01-29 11:35:03 +00:00
router!.currentState!.toSegments([isStory ? 'stories' : 'rooms', roomId]);
2021-02-07 16:18:38 +00:00
} catch (e, s) {
Logs().e('[Push] Failed to open room', e, s);
}
}
Future<void> setupUp() async {
2022-04-05 05:49:28 +00:00
await UnifiedPush.registerAppWithDialog(context!);
2021-02-07 16:18:38 +00:00
}
2022-04-05 05:49:28 +00:00
Future<void> _newUpEndpoint(String newEndpoint, String i) async {
upAction = true;
2022-01-29 11:35:03 +00:00
if (newEndpoint.isEmpty) {
2022-04-05 05:49:28 +00:00
await _upUnregistered(i);
2021-02-07 16:18:38 +00:00
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 12:19:54 +00:00
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')) {
2021-02-07 16:18:38 +00:00
endpoint = url;
}
} catch (e) {
Logs().i(
2022-08-14 14:59:21 +00:00
'[Push] No self-hosted unified push gateway present: $newEndpoint');
2021-02-07 16:18:38 +00:00
}
2022-08-14 14:59:21 +00:00
Logs().i('[Push] UnifiedPush using endpoint $endpoint');
2022-01-29 11:35:03 +00:00
final oldTokens = <String?>{};
2021-02-07 16:18:38 +00:00
try {
2022-04-14 05:34:55 +00:00
final fcmToken = await firebase?.getToken();
2021-02-07 16:18:38 +00: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);
}
2022-04-05 05:49:28 +00:00
Future<void> _upUnregistered(String i) async {
upAction = true;
2021-02-07 16:18:38 +00:00
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},
);
}
}
2022-04-05 05:49:28 +00:00
Future<void> _onUpMessage(Uint8List message, String i) async {
upAction = true;
2022-04-05 05:49:28 +00:00
final data = Map<String, dynamic>.from(
json.decode(utf8.decode(message))['notification']);
2022-05-06 08:26:35 +00:00
// UP may strip the devices list
data['devices'] ??= [];
2022-04-14 05:34:55 +00:00
await pushHelper(
PushNotification.fromJson(data),
client: client,
l10n: l10n,
activeRoomId: router?.currentState?.pathParameters['roomid'],
);
2021-02-07 16:18:38 +00:00
}
/// 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.
2022-01-29 11:35:03 +00:00
late Map<String, int> idMap;
2021-02-07 16:18:38 +00:00
Future<void> _loadIdMap() async {
2022-01-29 11:35:03 +00:00
idMap = Map<String, int>.from(json.decode(
2021-02-07 16:18:38 +00:00
(await store.getItem(SettingKeys.notificationCurrentIds)) ?? '{}'));
}
Future<int> mapRoomIdToInt(String roomId) async {
await _loadIdMap();
2022-01-29 11:35:03 +00:00
int? currentInt;
2021-02-07 16:18:38 +00:00
try {
currentInt = idMap[roomId];
} catch (_) {
currentInt = null;
}
if (currentInt != null) {
return currentInt;
}
2022-01-29 11:35:03 +00:00
var nCurrentInt = 0;
2021-02-07 16:18:38 +00:00
while (idMap.values.contains(currentInt)) {
2022-01-29 11:35:03 +00:00
nCurrentInt++;
2021-02-07 16:18:38 +00:00
}
2022-01-29 11:35:03 +00:00
idMap[roomId] = nCurrentInt;
2021-02-07 16:18:38 +00:00
await store.setItem(SettingKeys.notificationCurrentIds, json.encode(idMap));
2022-01-29 11:35:03 +00:00
return nCurrentInt;
2021-02-07 16:18:38 +00:00
}
bool _clearingPushLock = false;
Future<void> _onClearingPush({bool getFromServer = true}) async {
if (_clearingPushLock) {
return;
}
try {
_clearingPushLock = true;
2022-01-29 11:35:03 +00:00
late Iterable<String> emptyRooms;
2021-02-07 16:18:38 +00:00
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
2022-01-29 11:35:03 +00:00
.where((r) => r.notificationCount == 0)
2021-02-07 16:18:38 +00:00
.map((r) => r.id);
}
}
if (syncErrored) {
try {
Logs().v(
'[Push] failed to sync for fallback push, fetching notifications endpoint...');
2021-05-20 11:59:55 +00:00
final notifications = await client.getNotifications(limit: 20);
2021-02-07 16:18:38 +00: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
2022-01-29 11:35:03 +00:00
.where((r) => r.notificationCount == 0)
2021-02-07 16:18:38 +00:00
.map((r) => r.id);
}
}
} else {
emptyRooms = client.rooms
2022-01-29 11:35:03 +00:00
.where((r) => r.notificationCount == 0)
2021-02-07 16:18:38 +00:00
.map((r) => r.id);
}
await _loadIdMap();
var changed = false;
for (final roomId in emptyRooms) {
2022-01-29 11:35:03 +00:00
final id = idMap[roomId];
if (id != null) {
2021-02-07 16:18:38 +00:00
idMap.remove(roomId);
changed = true;
2022-01-29 11:35:03 +00:00
await _flutterLocalNotificationsPlugin.cancel(id);
2021-02-07 16:18:38 +00:00
}
}
if (changed) {
await store.setItem(
SettingKeys.notificationCurrentIds, json.encode(idMap));
}
} finally {
_clearingPushLock = false;
}
}
}