import 'package:moxxyv2/xmpp/events.dart'; import 'package:moxxyv2/xmpp/jid.dart'; import 'package:moxxyv2/xmpp/managers/base.dart'; import 'package:moxxyv2/xmpp/managers/data.dart'; import 'package:moxxyv2/xmpp/managers/handlers.dart'; import 'package:moxxyv2/xmpp/managers/namespaces.dart'; import 'package:moxxyv2/xmpp/namespaces.dart'; import 'package:moxxyv2/xmpp/negotiators/namespaces.dart'; import 'package:moxxyv2/xmpp/negotiators/negotiator.dart'; import 'package:moxxyv2/xmpp/stanza.dart'; import 'package:moxxyv2/xmpp/stringxml.dart'; import 'package:moxxyv2/xmpp/types/error.dart'; const rosterErrorNoQuery = 1; const rosterErrorNonResult = 2; class XmppRosterItem { XmppRosterItem({ required this.jid, required this.subscription, this.ask, this.name, this.groups = const [] }); final String jid; final String? name; final String subscription; final String? ask; final List groups; } enum RosterRemovalResult { okay, error, itemNotFound } class RosterRequestResult { RosterRequestResult({ required this.items, this.ver }); List items; String? ver; } class RosterPushEvent extends XmppEvent { RosterPushEvent({ required this.item, this.ver }); final XmppRosterItem item; final String? ver; } /// A Stub feature negotiator for finding out whether roster versioning is supported. class RosterFeatureNegotiator extends XmppFeatureNegotiatorBase { RosterFeatureNegotiator() : _supported = false, super(11, false, rosterVersioningXmlns, rosterNegotiator); /// True if rosterVersioning is supported. False otherwise. bool _supported; bool get isSupported => _supported; @override Future negotiate(XMLNode nonza) async { // negotiate is only called when the negotiator matched, meaning the server // advertises roster versioning. _supported = true; state = NegotiatorState.done; } @override void reset() { _supported = false; super.reset(); } } /// This manager requires a RosterFeatureNegotiator to be registered. class RosterManager extends XmppManagerBase { RosterManager() : _rosterVersion = null, super(); String? _rosterVersion; @override String getId() => rosterManager; @override String getName() => 'RosterManager'; @override List getIncomingStanzaHandlers() => [ StanzaHandler( stanzaTag: 'iq', tagName: 'query', tagXmlns: rosterXmlns, callback: _onRosterPush, ) ]; /// Override-able functions Future commitLastRosterVersion(String version) async {} Future loadLastRosterVersion() async {} void setRosterVersion(String ver) { assert(_rosterVersion == null, 'A roster version must not be empty'); _rosterVersion = ver; } Future _onRosterPush(Stanza stanza, StanzaHandlerData state) async { final attrs = getAttributes(); final from = stanza.attributes['from'] as String?; final selfJid = attrs.getConnectionSettings().jid; logger.fine('Received roster push'); // Only allow the push if the from attribute is either // - empty, i.e. not set // - a full JID of our own if (from != null && JID.fromString(from).toBare() != selfJid) { logger.warning('Roster push invalid! Unexpected from attribute: ${stanza.toXml()}'); return state.copyWith(done: true); } final query = stanza.firstTag('query', xmlns: rosterXmlns)!; final item = query.firstTag('item'); if (item == null) { logger.warning('Received empty roster push'); return state.copyWith(done: true); } if (query.attributes['ver'] != null) { final ver = query.attributes['ver']! as String; await commitLastRosterVersion(ver); _rosterVersion = ver; } attrs.sendEvent(RosterPushEvent( item: XmppRosterItem( jid: item.attributes['jid']! as String, subscription: item.attributes['subscription']! as String, ask: item.attributes['ask'] as String?, name: item.attributes['name'] as String?, ), ver: query.attributes['ver'] as String?, ),); await attrs.sendStanza(stanza.reply()); return state.copyWith(done: true); } /// Shared code between requesting rosters without and with roster versioning, if /// the server deems a regular roster response more efficient than n roster pushes. Future> _handleRosterResponse(XMLNode? query) async { final List items; if (query != null) { items = query.children.map((item) => XmppRosterItem( name: item.attributes['name'] as String?, jid: item.attributes['jid']! as String, subscription: item.attributes['subscription']! as String, ask: item.attributes['ask'] as String?, groups: item.findTags('group').map((groupNode) => groupNode.innerText()).toList(), ),).toList(); if (query.attributes['ver'] != null) { final ver_ = query.attributes['ver']! as String; await commitLastRosterVersion(ver_); _rosterVersion = ver_; } } else { logger.warning('Server response to roster request without roster versioning does not contain a element, while the type is not error. This violates RFC6121'); return MayFail.failure(rosterErrorNoQuery); } final ver = query.attributes['ver'] as String?; if (ver != null) { _rosterVersion = ver; await commitLastRosterVersion(ver); } return MayFail.success( RosterRequestResult( items: items, ver: ver, ), ); } /// Requests the roster following RFC 6121 without using roster versioning. Future> requestRoster() async { final attrs = getAttributes(); final response = await attrs.sendStanza( Stanza.iq( type: 'get', children: [ XMLNode.xmlns( tag: 'query', xmlns: rosterXmlns, ) ], ), ); if (response.attributes['type'] != 'result') { logger.warning('Error requesting roster without roster versioning: ${response.toXml()}'); return MayFail.failure(rosterErrorNonResult); } final query = response.firstTag('query', xmlns: rosterXmlns); return _handleRosterResponse(query); } /// Requests a series of roster pushes according to RFC6121. Requires that the server /// advertises urn:xmpp:features:rosterver in the stream features. Future> requestRosterPushes() async { if (_rosterVersion == null) { await loadLastRosterVersion(); } final attrs = getAttributes(); final result = await attrs.sendStanza( Stanza.iq( type: 'get', children: [ XMLNode.xmlns( tag: 'query', xmlns: rosterXmlns, attributes: { 'ver': _rosterVersion ?? '' }, ) ], ), ); if (result.attributes['type'] != 'result') { logger.warning('Requesting roster pushes failed: ${result.toXml()}'); return MayFail.failure(rosterErrorNonResult); } final query = result.firstTag('query', xmlns: rosterXmlns); return _handleRosterResponse(query); } bool rosterVersioningAvailable() { return getAttributes().getNegotiatorById(rosterNegotiator)!.isSupported; } /// Attempts to add [jid] with a title of [title] and groups [groups] to the roster. /// Returns true if the process was successful, false otherwise. Future addToRoster(String jid, String title, { List? groups }) async { final attrs = getAttributes(); final response = await attrs.sendStanza( Stanza.iq( type: 'set', children: [ XMLNode.xmlns( tag: 'query', xmlns: rosterXmlns, children: [ XMLNode( tag: 'item', attributes: { 'jid': jid, ...title == jid.split('@')[0] ? {} : { 'name': title } }, children: (groups ?? []).map((group) => XMLNode(tag: 'group', text: group)).toList(), ) ], ) ], ), ); if (response.attributes['type'] != 'result') { logger.severe('Error adding $jid to roster: $response'); return false; } return true; } /// Attempts to remove [jid] from the roster. Returns true if the process was successful, /// false otherwise. Future removeFromRoster(String jid) async { final attrs = getAttributes(); final response = await attrs.sendStanza( Stanza.iq( type: 'set', children: [ XMLNode.xmlns( tag: 'query', xmlns: rosterXmlns, children: [ XMLNode( tag: 'item', attributes: { 'jid': jid, 'subscription': 'remove' }, ) ], ) ], ), ); if (response.attributes['type'] != 'result') { logger.severe('Failed to remove roster item: ${response.toXml()}'); final error = response.firstTag('error')!; final notFound = error.firstTag('item-not-found') != null; if (notFound) { logger.warning('Item was not found'); return RosterRemovalResult.itemNotFound; } return RosterRemovalResult.error; } return RosterRemovalResult.okay; } }