import 'dart:async'; import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; import 'package:matrix/matrix.dart'; import 'package:unifiedpush/unifiedpush.dart'; import 'notifications.dart'; /// Push notifications through UnifiedPush. /// /// The user picks a distributor app (ntfy, or another UnifiedPush /// distributor) which gives this app an endpoint URL. The endpoint becomes a /// Matrix pusher: the homeserver posts each notification to the /// distributor's Matrix gateway, the distributor wakes the app, and the app /// fetches the event (decrypting it if it can) and shows it. /// /// Pushes are `event_id_only`, so neither the gateway nor the distributor /// sees message content, not even in unencrypted rooms. /// /// No Google services are involved, so this works without a Firebase /// project; the cost is that the user needs a distributor installed. class Push { Push._(); static const appId = 'io.github.dariuszjaniak.warp'; static const _endpointKey = 'warp.push.endpoint'; static const _storage = FlutterSecureStorage(); /// Where a push goes when the distributor's server has no Matrix gateway /// of its own: the UnifiedPush project's public one, which forwards to any /// endpoint. static const fallbackGateway = 'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify'; static Client? _client; /// Sets up the push receiver. Called on every start, in the foreground and /// when a push starts the app in the background. static Future init(Client client) async { _client = client; final registered = await UnifiedPush.initialize( onNewEndpoint: (endpoint, _) => _onNewEndpoint(endpoint.url), onUnregistered: (_) => _onUnregistered(), onRegistrationFailed: (reason, _) => Logs().w('UnifiedPush registration failed: $reason'), onMessage: (message, _) => _onMessage(message), ); // The connector wants a register() on every start to keep its // registration with the distributor alive. if (registered && client.isLogged()) { await UnifiedPush.register(features: const []); } } /// The installed distributors, by package name. static Future> distributors() => UnifiedPush.getDistributors(); static Future currentDistributor() => UnifiedPush.getDistributor(); /// Turns push on with [distributor], or the default one when null. Returns /// false when there is no distributor to use and the user has to pick one. static Future enable({String? distributor}) async { if (distributor != null) { await UnifiedPush.saveDistributor(distributor); } else if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) { return false; } await UnifiedPush.register(features: const []); return true; } /// Turns push off: unregisters from the distributor and removes the /// pusher from the homeserver. static Future disable() async { await UnifiedPush.unregister(); await _onUnregistered(); } /// Removes this device's pusher, when signing out. The homeserver also /// removes it when the session ends, so this is best effort. static Future forget() async { try { await UnifiedPush.unregister(); } catch (_) {} await _storage.delete(key: _endpointKey); } static Future _onNewEndpoint(String endpoint) async { final client = _client; if (client == null || !client.isLogged()) return; final previous = await _storage.read(key: _endpointKey); if (previous != null && previous != endpoint) { await _deletePusher(client, previous); } final gateway = await _gatewayFor(endpoint); await client.postPusher( Pusher( appId: appId, pushkey: endpoint, appDisplayName: 'Warp', deviceDisplayName: client.deviceName ?? 'Warp Android', kind: 'http', lang: 'en', data: PusherData(url: Uri.parse(gateway), format: 'event_id_only'), ), append: false, ); await _storage.write(key: _endpointKey, value: endpoint); Logs().i('Pusher registered with gateway $gateway'); } static Future _onUnregistered() async { final client = _client; final endpoint = await _storage.read(key: _endpointKey); if (client != null && endpoint != null && client.isLogged()) { await _deletePusher(client, endpoint); } await _storage.delete(key: _endpointKey); } static Future _deletePusher(Client client, String endpoint) async { try { await client.deletePusher(PusherId(appId: appId, pushkey: endpoint)); } catch (e) { Logs().w('Could not remove the old pusher', e); } } /// The Matrix gateway for [endpoint]. UnifiedPush's discovery: a server /// that has one answers `GET /_matrix/push/v1/notify` with /// `{"unifiedpush": {"gateway": "matrix"}}`. static Future _gatewayFor(String endpoint) async { final uri = Uri.parse(endpoint); final candidate = uri.replace( path: '/_matrix/push/v1/notify', query: null, fragment: null, ); try { final response = await http .get(candidate) .timeout(const Duration(seconds: 10)); if (response.statusCode == 200) { final json = jsonDecode(response.body); if (json is Map && json['unifiedpush'] is Map && json['unifiedpush']['gateway'] == 'matrix') { return candidate.toString(); } } } catch (e) { Logs().v('No Matrix gateway at ${candidate.origin}', e); } return fallbackGateway; } static Future _onMessage(PushMessage message) async { final client = _client; Map notification; try { final json = jsonDecode(utf8.decode(message.content)); notification = Map.from( (json is Map && json['notification'] is Map) ? json['notification'] as Map : json as Map, ); } catch (e) { Logs().w('Push that is not a Matrix notification', e); return; } final push = PushNotification.fromJson(notification); final roomId = push.roomId; // A push with no event is the homeserver saying the counts changed: // everything was read on another device. if (push.eventId == null || roomId == null) { if ((push.counts?.unread ?? 0) == 0) await Notifications.clearAll(); return; } if (client == null) return; try { // Null when the event has already been seen (read on another device, // or shown from the sync while the app was running). final event = await client.getEventByPushNotification(push); if (event != null) await Notifications.showEvent(event); return; } catch (e, s) { Logs().w('Could not fetch the pushed event', e, s); } // Fetching failed (no network, or the session is gone): still say // something arrived, without content. final room = client.getRoomById(roomId); await Notifications.show( roomId: roomId, title: room?.getLocalizedDisplayname() ?? push.roomName ?? push.senderDisplayName ?? 'Warp', body: 'New message', ); } }