320 lines
9.4 KiB
Dart
320 lines
9.4 KiB
Dart
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<String> groups;
|
|
}
|
|
|
|
enum RosterRemovalResult {
|
|
okay,
|
|
error,
|
|
itemNotFound
|
|
}
|
|
|
|
class RosterRequestResult {
|
|
|
|
RosterRequestResult({ required this.items, this.ver });
|
|
List<XmppRosterItem> 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<void> 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<StanzaHandler> getIncomingStanzaHandlers() => [
|
|
StanzaHandler(
|
|
stanzaTag: 'iq',
|
|
tagName: 'query',
|
|
tagXmlns: rosterXmlns,
|
|
callback: _onRosterPush,
|
|
)
|
|
];
|
|
|
|
/// Override-able functions
|
|
Future<void> commitLastRosterVersion(String version) async {}
|
|
Future<void> loadLastRosterVersion() async {}
|
|
|
|
void setRosterVersion(String ver) {
|
|
assert(_rosterVersion == null, 'A roster version must not be empty');
|
|
|
|
_rosterVersion = ver;
|
|
}
|
|
|
|
Future<StanzaHandlerData> _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<MayFail<RosterRequestResult>> _handleRosterResponse(XMLNode? query) async {
|
|
final List<XmppRosterItem> 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 <query /> 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<MayFail<RosterRequestResult>> 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<MayFail<RosterRequestResult?>> 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<RosterFeatureNegotiator>(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<bool> addToRoster(String jid, String title, { List<String>? 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: <String, String>{
|
|
'jid': jid,
|
|
...title == jid.split('@')[0] ? <String, String>{} : <String, String>{ '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<RosterRemovalResult> 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: <String, String>{
|
|
'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;
|
|
}
|
|
}
|