/*
* 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 .
*/
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';
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';
import '../config/app_config.dart';
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';
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 apl;
String _fcmToken;
LoginState _loginState;
L10n l10n;
Store _store;
Store get store => _store ??= Store();
Future 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 = >{};
DateTime lastReceivedPush;
BackgroundPush._(this.client) {
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(FluffyClient client) {
_instance ??= BackgroundPush._(client);
return _instance;
}
factory BackgroundPush(FluffyClient _client, BuildContext _context,
GlobalKey _apl) {
final instance = BackgroundPush.clientOnly(_client);
instance.context = _context;
instance.apl = _apl;
instance.fullInit();
return instance;
}
Future fullInit() async {
await setupPush();
}
void handleLoginStateChanged(LoginState state) {
_loginState = state;
setupPush();
}
void _newFcmToken(String token) {
_fcmToken = token;
setupPush();
}
final _fcmSharedIsolate = FcmSharedIsolate();
StreamSubscription onLogin;
StreamSubscription onRoomSync;
Future setupPusher({
String gatewayUrl,
String token,
Set oldTokens,
bool useDeviceSpecificAppId = false,
}) async {
if (PlatformInfos.isIOS) {
await _fcmSharedIsolate.requestPermission();
}
final clientName = PlatformInfos.clientName;
oldTokens ??= {};
final pushers = await client.getPushers().catchError((e) {
Logs().w('[Push] Unable to request pushers', e);
return [];
});
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 {
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)) {
pusher.kind = null;
try {
await client.postPusher(
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 {
await client.postPusher(
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;
Future setupPush() async {
await setupLocalNotificationsPlugin();
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);
});
}
Future _noFcmWarning() async {
if (context == null) {
return;
}
if (await store.getItemBool(SettingKeys.showNoGoogle, true)) {
await loadLocale();
apl.currentState.showSnackBar(SnackBar(
content: Text(
PlatformInfos.isAndroid
? l10n.noGoogleServicesWarning
: l10n.oopsPushError,
)));
if (null == await store.getItem(SettingKeys.showNoGoogle)) {
await store.setItemBool(SettingKeys.showNoGoogle, false);
}
}
}
Future setupFirebase() async {
if (_fcmToken?.isEmpty ?? true) {
try {
_fcmToken = await _fcmSharedIsolate.getToken();
} catch (e, s) {
Logs().e('[Push] cannot get token', e, s);
await _noFcmWarning();
return;
}
}
await setupPusher(
gatewayUrl: AppConfig.pushNotificationsGatewayUrl,
token: _fcmToken,
);
}
Future goToRoom(String roomId) async {
try {
Logs().v('[Push] Attempting to go to room $roomId...');
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 setupLocalNotificationsPlugin() async {
if (_notificationsPluginSetUp) {
return;
}
// initialise the plugin. app_icon needs to be a added as a drawable resource to the Android head project
final initializationSettingsAndroid =
AndroidInitializationSettings('notifications_icon');
final initializationSettingsIOS =
IOSInitializationSettings(onDidReceiveLocalNotification: (i, a, b, c) {
return null;
});
final initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsIOS,
);
await _flutterLocalNotificationsPlugin.initialize(initializationSettings,
onSelectNotification: goToRoom);
_notificationsPluginSetUp = true;
}
Future 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();
}
Future _onFcmMessage(Map message) async {
Logs().v('[Push] Foreground message received');
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(data);
}
}
Future _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;
final res =
json.decode(utf8.decode((await http.get(Uri.parse(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 _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 _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 _onUpMessage(String message) async {
Map data;
try {
data = Map.from(json.decode(message)['notification']);
await _onMessage(data);
} catch (e, s) {
Logs().e('[Push] Error while processing notification', e, s);
await _showDefaultNotification(data);
}
}
Future _onMessage(Map 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('counts', '{}'))
: data.tryGet