diff --git a/lib/extensions/caps/README.markdown b/lib/extensions/caps/README.markdown new file mode 100644 index 0000000..c2eea37 --- /dev/null +++ b/lib/extensions/caps/README.markdown @@ -0,0 +1,50 @@ +# Entity Capabilities + +This extension provides support for handling capabilities in the XMPP protocol using Caps (short for **Capabilities**) feature. Allows a client to advertise its capabilities to other entities and discover the capabilities of other entities in the XMPP network. + +## Features + +- Advertise and discover capabilities of XMPP entities. +- Efficiently handle capabilities updates to avoid unnecessary queries. +- Automatic capabilities exchange during XMPP connection establishment. + +## Limitations + +Please note that you need to use `echo.disco.addFeature` and `echo.disco.addIdentity` methods to add capabilities features and identity to the client. This extension does not provide built-in methods for adding features and identities to the disco extension. + +## Embedding + +This extension comes built-in to the client. It means you do not need to attach this extension as you did on other extensions. You can not disable or enable this feature in any way. + +## API + +This code snippet demonstrates how to use this extension for the client. + +```dart + +import 'dart:async'; +import 'dart:developer'; + +import 'package:echo/echo.dart'; + +Future main() async { + final echo = Echo(service: 'ws://example.com:5443/ws'); + + await echo.connect( + jid: 'vsevex@example.com', + password: 'randompasswordwhichisgoingtobeyourpassword', + callback: (status) async { + if (status == EchoStatus.connected) { + log('Connection Established'); + echo.disco.addFeature('someFeature'); + echo.caps.sendPresence(); + } else if (status == EchoStatus.disconnected) { + log('Connection Terminated'); + } else if (status == EchoStatus.register) { + registrationCompleter.complete(true); + } + }, + ); +} + +``` diff --git a/lib/extensions/caps/caps_extension.dart b/lib/extensions/caps/caps_extension.dart new file mode 100644 index 0000000..47af14b --- /dev/null +++ b/lib/extensions/caps/caps_extension.dart @@ -0,0 +1,182 @@ +import 'package:echo/echo.dart'; +import 'package:echo/src/constants.dart'; + +/// Represents Caps (short for Entity Capabilities) plugin. +/// +/// CapsExtension is an implementation of the [Extension] class that provides +/// support for handling capabilities in the XMPP protocol using the +/// Capabilities feature. +/// +/// This extension allows the client to advertise its capabilities to other +/// entities and discover the capabilities of other entities. +class CapsExtension extends Extension { + /// Creates an instance of the [CapsExtension] with the provided extension + /// name. + /// + /// For more information about this extension please refer to [CapsExtension] + /// or `Readme`. + CapsExtension() : super('caps-extension'); + + /// A [Map] to store known capabilities of other entities identified by the + /// `verification` attribute received in the presence stanzas. + final _knownCapabilities = >>{}; + + /// A [Map] to store the `verification` attribute received in the presence + /// from different entities, indexed by their respective JIDs. + final _jidIndex = {}; + + /// Called when the connection status changes. + @override + void changeStatus(EchoStatus status, String? condition) { + /// If the status is in `connected` state, it sends the client's presence + /// with its capabilities. + if (status == EchoStatus.connected) { + sendPresence(); + } + } + + @override + void initialize(Echo echo) { + /// CAPS feature namespace + echo.addNamespace('CAPS', 'http://jabber.org/protocol/caps'); + + /// Add the `CAPS` feature and client identity to the disco extension. + echo.disco.addFeature(ns['CAPS']!); + echo.disco.addIdentity(category: 'client', type: 'mobile', name: 'echo'); + + /// Set up presence handler to process the incoming presence stanzas. + echo.addHandler( + (stanza) { + final from = stanza.getAttribute('from'); + final c = stanza.findAllElements('c').first; + final ver = c.getAttribute('ver'); + final node = c.getAttribute('node'); + + if (!_knownCapabilities.containsKey(ver)) { + /// If the capabilities are not known, request capabilities from the + /// entity. + return _requestCapabilities(to: from!, node: node!, ver: ver!); + } else { + _jidIndex[from!] = ver!; + } + if (!_jidIndex.containsKey(from) || _jidIndex[from] != ver) { + _jidIndex[from] = ver; + } + return true; + }, + namespace: ns['CAPS'], + name: 'presence', + ); + + super.echo = echo; + } + + /// Requests capabilities from the given entity (identified by `to` JID) with + /// the provided `node` and `ver` attributes. + /// + /// * @param to The Jabber Identifier to indicate who the request is going to. + /// * @param node A Unique Identifier for the capabilities being queried. It + /// helps in distinguishing between different sets of caps provided by the + /// same entity. + /// * @param ver Stands for verification string and helps preventing poisoning + /// of entity capabilities information. + Future _requestCapabilities({ + required String to, + required String node, + required String ver, + }) async { + if (to != echo!.jid) { + await echo!.disco + .info(to, node: '$node#$ver', resultCallback: _handleDiscoInfoReply); + } + return true; + } + + /// Handles the reply to the disco#info query and updates the known + /// capabilities for the entity identified by 'from' JID. + bool _handleDiscoInfoReply(XmlElement stanza) { + final query = stanza.findAllElements('query').first; + final node = query.getAttribute('node')!.split('#'); + final ver = node.first; + final from = stanza.getAttribute('from'); + + if (!_knownCapabilities.containsKey(ver) || + _knownCapabilities[ver] == null) { + /// If the capabilities are not known, add them to the knownCapabilities + /// [Map]. + final nodes = query.descendantElements.toList(); + _knownCapabilities[ver] = []; + for (int i = 0; i < nodes.length; i++) { + final node = nodes[i]; + _knownCapabilities[ver]! + .add({'name': node.name.local, 'attributes': node.attributes}); + } + _jidIndex[from!] = ver; + } else if (_jidIndex[from] == null || _jidIndex[from] != ver) { + _jidIndex[from!] = ver; + } + + return false; + } + + /// Generates the attributes for the 'c' (capabilities) node in the client's + /// presence. + Map get _generateCapsAttributes => { + 'xmlns': ns['CAPS']!, + 'hash': 'sha-1', + 'node': 'echo 0.0.6<', + 'ver': _generateVerificationString, + }; + + /// Creates the 'c' (capabilities) node for the client's presence. + XmlElement? get _createCapsNode => + EchoBuilder('c', _generateCapsAttributes).nodeTree; + + void sendPresence() => echo!.send(EchoBuilder.pres().cnode(_createCapsNode!)); + + /// Generates the verification string for the client's capabilities based on + /// the identities and features supported by the client. + /// + /// For more information about this string please refer to the documentation: + /// https://xmpp.org/extensions/xep-0115.html#ver + String get _generateVerificationString { + final verificationStringBuffer = StringBuffer(); + final identities = echo!.disco.identities; + _sort(identities, 'category'); + _sort(identities, 'type'); + _sort(identities, 'language'); + final features = echo!.disco.features..sort(); + + for (int i = 0; i < identities.length; i++) { + final id = identities[i]; + verificationStringBuffer + ..writeAll([id.category, id.type, id.language], '/') + ..write(id.name) + ..write('<'); + } + + for (int i = 0; i < features.length; i++) { + verificationStringBuffer + ..write(features[i]) + ..write('<'); + } + + return Echotils.btoa( + Echotils.utf16to8(verificationStringBuffer.toString()), + ); + } + + /// Sorts the list of [DiscoIdentity] objects based on the specified + /// 'property'. The sorting is done in-place and returns the sorted list of + /// identities. + List _sort(List identities, String property) { + if (property == 'category') { + identities.sort((i1, i2) => i1.category.compareTo(i2.category)); + } else if (property == 'type') { + identities.sort((i1, i2) => i1.type.compareTo(i2.type)); + } else if (property == 'language') { + identities.sort((i1, i2) => i1.language!.compareTo(i2.language!)); + } + return identities; + } +} diff --git a/lib/extensions/disco/disco_extension.dart b/lib/extensions/disco/disco_extension.dart index b52d786..2e5fd65 100644 --- a/lib/extensions/disco/disco_extension.dart +++ b/lib/extensions/disco/disco_extension.dart @@ -21,10 +21,10 @@ class DiscoExtension extends Extension { late final _items = []; /// The list of feature strings. - late final _features = []; + late final features = []; /// The list of [DiscoIdentity] objects. - late final _identities = []; + late final identities = []; /// This method is not implemented and will not be affected in the use of this /// extension. @@ -72,16 +72,16 @@ class DiscoExtension extends Extension { String name = '', String language = '', }) { - for (int i = 0; i < _identities.length; i++) { - if (_identities[i].category == category && - _identities[i].type == type && - _identities[i].name == name && - _identities[i].language == language) { + for (int i = 0; i < identities.length; i++) { + if (identities[i].category == category && + identities[i].type == type && + identities[i].name == name && + identities[i].language == language) { return false; } } - _identities.add( + identities.add( DiscoIdentity( category: category, type: type, @@ -110,6 +110,7 @@ class DiscoExtension extends Extension { FutureOr Function(EchoException)? errorCallback, int? timeout, }) async { + final id = super.echo!.getUniqueId('info'); final attributes = {'xmlns': ns['DISCO_INFO']!}; if (node != null) { @@ -117,7 +118,7 @@ class DiscoExtension extends Extension { } final info = EchoBuilder.iq( - attributes: {'from': echo!.jid, 'to': jid, 'type': 'get'}, + attributes: {'from': super.echo!.jid, 'to': jid, 'type': 'get', 'id': id}, ).c('query', attributes: attributes); return echo!.sendIQ( @@ -125,7 +126,6 @@ class DiscoExtension extends Extension { resultCallback: resultCallback, errorCallback: errorCallback, waitForResult: true, - timeout: timeout, ); } @@ -171,12 +171,12 @@ class DiscoExtension extends Extension { /// * @return `true` if the feature was added successfully, or `false` if the /// feature already exists. bool addFeature(String variableName) { - for (int i = 0; i < _features.length; i++) { - if (_features[i] == variableName) { + for (int i = 0; i < features.length; i++) { + if (features[i] == variableName) { return false; } } - _features.add(variableName); + features.add(variableName); return true; } @@ -186,9 +186,9 @@ class DiscoExtension extends Extension { /// * @return `true` feature was removed successfully, or `false` if the /// feature does not exist. bool removeFeature(String variableName) { - for (int i = 0; i < _features.length; i++) { - if (_features[i] == variableName) { - _features.removeAt(i); + for (int i = 0; i < features.length; i++) { + if (features[i] == variableName) { + features.removeAt(i); return true; } } @@ -209,25 +209,25 @@ class DiscoExtension extends Extension { final iqResult = _buildIQResult(stanza: stanza, queryAttributes: attributes); - for (int i = 0; i < _identities.length; i++) { + for (int i = 0; i < identities.length; i++) { attributes = { - 'category': _identities[i].category, - 'type': _identities[i].type + 'category': identities[i].category, + 'type': identities[i].type }; - if (_identities[i].name != null) { - attributes['name'] = _identities[i].name!; + if (identities[i].name != null) { + attributes['name'] = identities[i].name!; } - if (_identities[i].language != null) { - attributes['language'] = _identities[i].language!; + if (identities[i].language != null) { + attributes['language'] = identities[i].language!; } iqResult.c('identity', attributes: attributes).up(); } - for (int i = 0; i < _features.length; i++) { - iqResult.c('feature', attributes: {'var': _features[i]}).up(); + for (int i = 0; i < features.length; i++) { + iqResult.c('feature', attributes: {'var': features[i]}).up(); } - echo!.send(iqResult.nodeTree); + echo!.send(iqResult); return true; } @@ -265,7 +265,7 @@ class DiscoExtension extends Extension { } iqResult.c('item', attributes: attributes).up(); } - echo!.send(iqResult.nodeTree); + echo!.send(iqResult); return true; } diff --git a/lib/extensions/extensions.dart b/lib/extensions/extensions.dart index 3fd9ec5..5d4e8e0 100644 --- a/lib/extensions/extensions.dart +++ b/lib/extensions/extensions.dart @@ -1,3 +1,4 @@ +export 'caps/caps_extension.dart'; export 'disco/disco_extension.dart'; export 'pubsub/pubsub_extension.dart'; export 'v-card/vcard_extension.dart'; diff --git a/lib/extensions/pubsub/pubsub_extension.dart b/lib/extensions/pubsub/pubsub_extension.dart index 1bcac77..15eb664 100644 --- a/lib/extensions/pubsub/pubsub_extension.dart +++ b/lib/extensions/pubsub/pubsub_extension.dart @@ -235,15 +235,8 @@ class PubSubExtension extends Extension { final completer = Completer>(); - echo!.addHandler( - callback, - resultCallback: resultCallback, - errorCallback: errorCallback, - completer: completer, - name: 'iq', - id: id, - ); - echo!.send(iq.nodeTree, completer); + echo!.addHandler(callback, completer: completer, name: 'iq', id: id); + await echo!.send(iq, completer, resultCallback, errorCallback); return id; } @@ -328,7 +321,7 @@ class PubSubExtension extends Extension { id: id, ); - echo!.send(iq.nodeTree); + echo!.send(iq); return id; } @@ -343,9 +336,7 @@ class PubSubExtension extends Extension { /// data. /// * @return A [String] resolves to the ID of the sent IQ stanza.This ID can /// be used to track the response or correlate it with the original request. - String getDefaultNodeConfig([ - FutureOr Function(XmlElement)? callback, - ]) { + String getDefaultNodeConfig([FutureOr Function(XmlElement)? callback]) { final id = echo!.getUniqueId('pubsubdefaultnodeconfig'); final iq = EchoBuilder.iq( @@ -353,7 +344,7 @@ class PubSubExtension extends Extension { ).c('pubsub', attributes: {'xmlns': ns['PUBSUB_OWNER']!}).c('default'); echo!.addHandler(callback, name: 'iq', id: id); - echo!.send(iq.nodeTree); + echo!.send(iq); return id; } @@ -520,16 +511,8 @@ class PubSubExtension extends Extension { final completer = Completer>(); - echo!.addHandler( - callback, - name: 'iq', - id: id, - resultCallback: resultCallback, - errorCallback: errorCallback, - completer: completer, - ); - - echo!.send(iq, completer); + echo!.addHandler(callback, name: 'iq', id: id, completer: completer); + await echo!.send(iq, completer, resultCallback, errorCallback); return id; } @@ -634,6 +617,12 @@ class PubSubExtension extends Extension { /// /// * @param node The identifier of the PubSub node for which the /// subscriptions are requested. + /// * @param resultCallback (Function) An optional callback function that + /// will be invoked when a successful response to the IQ stanza is received. + /// It can be used to process the received items XML and perform any necessary + /// actions. + /// * @param errorCallback An optional callback function that will be invoked + /// when an error response or no response to the IQ stanza is received. /// * @param callback (Function) An optional callback function that will be /// invoked when a response to the IQ stanza is received. This callback can /// be used to process the response or perform additional actions based on @@ -648,10 +637,12 @@ class PubSubExtension extends Extension { /// return true; /// }); /// ``` - String getNodeSubscriptions( + Future getNodeSubscriptions( String node, [ FutureOr Function(XmlElement)? callback, - ]) { + FutureOr Function(XmlElement)? resultCallback, + FutureOr Function(EchoException)? errorCallback, + ]) async { final id = echo!.getUniqueId('pubsubsubscriptions'); final iq = EchoBuilder.iq( @@ -661,8 +652,10 @@ class PubSubExtension extends Extension { attributes: {'node': node}, ); - echo!.addHandler(callback, name: 'iq', id: id); - echo!.send(iq.nodeTree); + final completer = Completer>(); + + echo!.addHandler(callback, completer: completer, name: 'iq', id: id); + await echo!.send(iq, completer, resultCallback, errorCallback); return id; } @@ -676,6 +669,12 @@ class PubSubExtension extends Extension { /// requested. /// * @param subID The identifier of the subscription for which options are /// requested. + /// * @param resultCallback (Function) An optional callback function that + /// will be invoked when a successful response to the IQ stanza is received. + /// It can be used to process the received items XML and perform any necessary + /// actions. + /// * @param errorCallback An optional callback function that will be invoked + /// when an error response or no response to the IQ stanza is received. /// * @param callback (Function) An optional callback function which returns /// bool or Future. This callback can be used to process the response or /// perform additional actions based on the received data. @@ -692,11 +691,13 @@ class PubSubExtension extends Extension { /// }, /// ); /// ``` - String getSubscriptionOptions( + Future getSubscriptionOptions( String node, { String? subID, FutureOr Function(XmlElement)? callback, - }) { + FutureOr Function(XmlElement)? resultCallback, + FutureOr Function(EchoException)? errorCallback, + }) async { final id = echo!.getUniqueId('pubsubsuboptions'); final iq = EchoBuilder.iq( @@ -715,8 +716,10 @@ class PubSubExtension extends Extension { iq.addAttributes({'subid': subID}); } - echo!.addHandler(callback, name: 'iq', id: id); - echo!.send(iq.nodeTree); + final completer = Completer>(); + + echo!.addHandler(callback, completer: completer, name: 'iq', id: id); + await echo!.send(iq, completer, resultCallback, errorCallback); return id; } diff --git a/lib/extensions/registration/registration_extension.dart b/lib/extensions/registration/registration_extension.dart index 1c8abab..f73b5fd 100644 --- a/lib/extensions/registration/registration_extension.dart +++ b/lib/extensions/registration/registration_extension.dart @@ -62,7 +62,7 @@ class RegistrationExtension extends Extension { final completer = Completer>(); /// Add system handler for accepting incoming stanzas. - super.echo!._addSystemHandler( + echo!._addSystemHandler( (stanza) { final query = stanza.findAllElements('query'); @@ -85,14 +85,6 @@ class RegistrationExtension extends Extension { ); /// Send stanza which built using [EchoBuilder.iq] constructor. - await super.echo!.send(query.nodeTree); - - /// Wait for the answer from `completer`. - final either = await completer.future; - - return either.fold( - (stanza) => resultCallback?.call(stanza), - (exception) => errorCallback?.call(exception), - ); + await echo!.send(query, completer, resultCallback, errorCallback); } } diff --git a/lib/src/echo.dart b/lib/src/echo.dart index 96b1659..fcd586a 100644 --- a/lib/src/echo.dart +++ b/lib/src/echo.dart @@ -133,6 +133,10 @@ class Echo { /// Initialize [DiscoExtension] class and attach to the current [Echo]. disco = DiscoExtension(); attachExtension(disco); + + /// Initialize [CapsExtension] class and attach to the current [Echo]. + caps = CapsExtension(); + attachExtension(caps); } /// `version` constant. @@ -268,6 +272,9 @@ class Echo { /// Late initialization of [DiscoExtension]. late final DiscoExtension disco; + /// Late initialization of [CapsExtension]. + late final CapsExtension caps; + /// The selected mechanism to provide authentication. late SASL? _mechanism; @@ -830,6 +837,8 @@ class Echo { FutureOr send( dynamic message, [ Completer>? completer, + FutureOr Function(xml.XmlElement stanza)? resultCallback, + FutureOr Function(EchoException)? errorCallback, ]) async { /// If the message is null or empty, exit from the function. if (message == null) return; @@ -858,10 +867,15 @@ class Echo { /// If `completer` param is not null, then wait for the incoming stanza /// result. if (completer != null) { - await completer.future.timeout( + final either = await completer.future.timeout( Duration(milliseconds: stanzaResponseTimeout), onTimeout: () => Right(EchoExceptionMapper.requestTimedOut()), ); + + either.fold( + (stanza) => resultCallback?.call(stanza), + (exception) => errorCallback?.call(exception), + ); } }