feat: Initial code
This commit is contained in:
parent
999e6c521a
commit
a3ced3a6da
10
.gitignore
vendored
10
.gitignore
vendored
@ -1,2 +1,12 @@
|
|||||||
.envrc
|
.envrc
|
||||||
.direnv/
|
.direnv/
|
||||||
|
# Files and directories created by pub.
|
||||||
|
.dart_tool/
|
||||||
|
.packages
|
||||||
|
|
||||||
|
# Conventional directory for build outputs.
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Omit committing pubspec.lock for library packages; see
|
||||||
|
# https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||||
|
pubspec.lock
|
||||||
|
3
moxxmpp/CHANGELOG.md
Normal file
3
moxxmpp/CHANGELOG.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
- Initial version copied over from Moxxyv2
|
7
moxxmpp/README.md
Normal file
7
moxxmpp/README.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# moxxmpp
|
||||||
|
|
||||||
|
A pure-Dart XMPP library written for Moxxy.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
See `../LICENSE`.
|
14
moxxmpp/analysis_options.yaml
Normal file
14
moxxmpp/analysis_options.yaml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
include: package:very_good_analysis/analysis_options.yaml
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
public_member_api_docs: false
|
||||||
|
lines_longer_than_80_chars: false
|
||||||
|
use_setters_to_change_properties: false
|
||||||
|
avoid_positional_boolean_parameters: false
|
||||||
|
avoid_bool_literals_in_conditional_expressions: false
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- "**/*.g.dart"
|
||||||
|
- "**/*.freezed.dart"
|
||||||
|
- "test/"
|
6
moxxmpp/example/moxxmpp_example.dart
Normal file
6
moxxmpp/example/moxxmpp_example.dart
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
var awesome = Awesome();
|
||||||
|
print('awesome: ${awesome.isAwesome}');
|
||||||
|
}
|
76
moxxmpp/lib/moxxmpp.dart
Normal file
76
moxxmpp/lib/moxxmpp.dart
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
library moxxmpp;
|
||||||
|
|
||||||
|
export 'package:moxxmpp/src/connection.dart';
|
||||||
|
export 'package:moxxmpp/src/events.dart';
|
||||||
|
export 'package:moxxmpp/src/iq.dart';
|
||||||
|
export 'package:moxxmpp/src/jid.dart';
|
||||||
|
export 'package:moxxmpp/src/managers/attributes.dart';
|
||||||
|
export 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
export 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
export 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
export 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
export 'package:moxxmpp/src/managers/priorities.dart';
|
||||||
|
export 'package:moxxmpp/src/message.dart';
|
||||||
|
export 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
export 'package:moxxmpp/src/negotiators/manager.dart';
|
||||||
|
export 'package:moxxmpp/src/negotiators/namespaces.dart';
|
||||||
|
export 'package:moxxmpp/src/negotiators/negotiator.dart';
|
||||||
|
export 'package:moxxmpp/src/negotiators/resource_binding.dart';
|
||||||
|
export 'package:moxxmpp/src/negotiators/sasl/negotiator.dart';
|
||||||
|
export 'package:moxxmpp/src/negotiators/sasl/plain.dart';
|
||||||
|
export 'package:moxxmpp/src/negotiators/sasl/scram.dart';
|
||||||
|
export 'package:moxxmpp/src/negotiators/starttls.dart';
|
||||||
|
export 'package:moxxmpp/src/ping.dart';
|
||||||
|
export 'package:moxxmpp/src/presence.dart';
|
||||||
|
export 'package:moxxmpp/src/reconnect.dart';
|
||||||
|
export 'package:moxxmpp/src/rfcs/rfc_2782.dart';
|
||||||
|
export 'package:moxxmpp/src/rfcs/rfc_4790.dart';
|
||||||
|
export 'package:moxxmpp/src/roster.dart';
|
||||||
|
export 'package:moxxmpp/src/settings.dart';
|
||||||
|
export 'package:moxxmpp/src/socket.dart';
|
||||||
|
export 'package:moxxmpp/src/stanza.dart';
|
||||||
|
export 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
export 'package:moxxmpp/src/types/error.dart';
|
||||||
|
export 'package:moxxmpp/src/types/resultv2.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/staging/extensible_file_thumbnails.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/staging/file_upload_notification.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0004.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0030/errors.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0030/helpers.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0030/types.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0054.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0060/errors.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0060/helpers.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0060/xep_0060.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0066.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0084.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0085.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0115.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0184.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0191.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0198/negotiator.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0198/nonzas.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0198/state.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0198/xep_0198.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0203.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0280.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0297.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0300.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0333.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0334.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0352.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0359.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0363.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0380.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0384/crypto.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0384/errors.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0384/helpers.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0384/types.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0384/xep_0384.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0385.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0414.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0446.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0447.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0448.dart';
|
||||||
|
export 'package:moxxmpp/src/xeps/xep_0461.dart';
|
27
moxxmpp/lib/src/buffer.dart
Normal file
27
moxxmpp/lib/src/buffer.dart
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
import 'package:xml/xml.dart';
|
||||||
|
import 'package:xml/xml_events.dart';
|
||||||
|
|
||||||
|
class XmlStreamBuffer extends StreamTransformerBase<String, XMLNode> {
|
||||||
|
|
||||||
|
XmlStreamBuffer() : _streamController = StreamController(), _decoder = const XmlNodeDecoder();
|
||||||
|
final StreamController<XMLNode> _streamController;
|
||||||
|
final XmlNodeDecoder _decoder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<XMLNode> bind(Stream<String> stream) {
|
||||||
|
stream.toXmlEvents().selectSubtreeEvents((event) {
|
||||||
|
return event.qualifiedName != 'stream:stream';
|
||||||
|
}).transform(_decoder).listen((nodes) {
|
||||||
|
for (final node in nodes) {
|
||||||
|
if (node.nodeType == XmlNodeType.ELEMENT) {
|
||||||
|
_streamController.add(XMLNode.fromXmlElement(node as XmlElement));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return _streamController.stream;
|
||||||
|
}
|
||||||
|
}
|
1033
moxxmpp/lib/src/connection.dart
Normal file
1033
moxxmpp/lib/src/connection.dart
Normal file
File diff suppressed because it is too large
Load Diff
206
moxxmpp/lib/src/events.dart
Normal file
206
moxxmpp/lib/src/events.dart
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import 'package:moxxmpp/src/connection.dart';
|
||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/types.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0060/xep_0060.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0066.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0085.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0359.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0385.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0446.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0447.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0461.dart';
|
||||||
|
|
||||||
|
abstract class XmppEvent {}
|
||||||
|
|
||||||
|
/// Triggered when the connection state of the XmppConnection has
|
||||||
|
/// changed.
|
||||||
|
class ConnectionStateChangedEvent extends XmppEvent {
|
||||||
|
ConnectionStateChangedEvent(this.state, this.before, this.resumed);
|
||||||
|
final XmppConnectionState before;
|
||||||
|
final XmppConnectionState state;
|
||||||
|
final bool resumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when we encounter a stream error.
|
||||||
|
class StreamErrorEvent extends XmppEvent {
|
||||||
|
StreamErrorEvent({ required this.error });
|
||||||
|
final String error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered after the SASL authentication has failed.
|
||||||
|
class AuthenticationFailedEvent extends XmppEvent {
|
||||||
|
AuthenticationFailedEvent(this.saslError);
|
||||||
|
final String saslError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered after the SASL authentication has succeeded.
|
||||||
|
class AuthenticationSuccessEvent extends XmppEvent {}
|
||||||
|
|
||||||
|
/// Triggered when we want to ping the connection open
|
||||||
|
class SendPingEvent extends XmppEvent {}
|
||||||
|
|
||||||
|
/// Triggered when the stream resumption was successful
|
||||||
|
class StreamResumedEvent extends XmppEvent {
|
||||||
|
StreamResumedEvent({ required this.h });
|
||||||
|
final int h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when stream resumption failed
|
||||||
|
class StreamResumeFailedEvent extends XmppEvent {}
|
||||||
|
|
||||||
|
class MessageEvent extends XmppEvent {
|
||||||
|
MessageEvent({
|
||||||
|
required this.body,
|
||||||
|
required this.fromJid,
|
||||||
|
required this.toJid,
|
||||||
|
required this.sid,
|
||||||
|
required this.stanzaId,
|
||||||
|
required this.isCarbon,
|
||||||
|
required this.deliveryReceiptRequested,
|
||||||
|
required this.isMarkable,
|
||||||
|
required this.encrypted,
|
||||||
|
required this.other,
|
||||||
|
this.type,
|
||||||
|
this.oob,
|
||||||
|
this.sfs,
|
||||||
|
this.sims,
|
||||||
|
this.reply,
|
||||||
|
this.chatState,
|
||||||
|
this.fun,
|
||||||
|
this.funReplacement,
|
||||||
|
this.funCancellation,
|
||||||
|
});
|
||||||
|
final String body;
|
||||||
|
final JID fromJid;
|
||||||
|
final JID toJid;
|
||||||
|
final String sid;
|
||||||
|
final String? type;
|
||||||
|
final StableStanzaId stanzaId;
|
||||||
|
final bool isCarbon;
|
||||||
|
final bool deliveryReceiptRequested;
|
||||||
|
final bool isMarkable;
|
||||||
|
final OOBData? oob;
|
||||||
|
final StatelessFileSharingData? sfs;
|
||||||
|
final StatelessMediaSharingData? sims;
|
||||||
|
final ReplyData? reply;
|
||||||
|
final ChatState? chatState;
|
||||||
|
final FileMetadataData? fun;
|
||||||
|
final String? funReplacement;
|
||||||
|
final String? funCancellation;
|
||||||
|
final bool encrypted;
|
||||||
|
final Map<String, dynamic> other;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when a client responds to our delivery receipt request
|
||||||
|
class DeliveryReceiptReceivedEvent extends XmppEvent {
|
||||||
|
DeliveryReceiptReceivedEvent({ required this.from, required this.id });
|
||||||
|
final JID from;
|
||||||
|
final String id;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatMarkerEvent extends XmppEvent {
|
||||||
|
ChatMarkerEvent({
|
||||||
|
required this.type,
|
||||||
|
required this.from,
|
||||||
|
required this.id,
|
||||||
|
});
|
||||||
|
final JID from;
|
||||||
|
final String type;
|
||||||
|
final String id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Triggered when we received a Stream resumption ID
|
||||||
|
class StreamManagementEnabledEvent extends XmppEvent {
|
||||||
|
StreamManagementEnabledEvent({
|
||||||
|
required this.resource,
|
||||||
|
this.id,
|
||||||
|
this.location,
|
||||||
|
});
|
||||||
|
final String resource;
|
||||||
|
final String? id;
|
||||||
|
final String? location;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when we bound a resource
|
||||||
|
class ResourceBindingSuccessEvent extends XmppEvent {
|
||||||
|
ResourceBindingSuccessEvent({ required this.resource });
|
||||||
|
final String resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when we receive presence
|
||||||
|
class PresenceReceivedEvent extends XmppEvent {
|
||||||
|
PresenceReceivedEvent(this.jid, this.presence);
|
||||||
|
final JID jid;
|
||||||
|
final Stanza presence;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when we are starting an connection attempt
|
||||||
|
class ConnectingEvent extends XmppEvent {}
|
||||||
|
|
||||||
|
/// Triggered when we found out what the server supports
|
||||||
|
class ServerDiscoDoneEvent extends XmppEvent {}
|
||||||
|
|
||||||
|
class ServerItemDiscoEvent extends XmppEvent {
|
||||||
|
ServerItemDiscoEvent(this.info);
|
||||||
|
final DiscoInfo info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when we receive a subscription request
|
||||||
|
class SubscriptionRequestReceivedEvent extends XmppEvent {
|
||||||
|
SubscriptionRequestReceivedEvent({ required this.from });
|
||||||
|
final JID from;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when we receive a new or updated avatar
|
||||||
|
class AvatarUpdatedEvent extends XmppEvent {
|
||||||
|
AvatarUpdatedEvent({ required this.jid, required this.base64, required this.hash });
|
||||||
|
final String jid;
|
||||||
|
final String base64;
|
||||||
|
final String hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when a PubSub notification has been received
|
||||||
|
class PubSubNotificationEvent extends XmppEvent {
|
||||||
|
PubSubNotificationEvent({ required this.item, required this.from });
|
||||||
|
final PubSubItem item;
|
||||||
|
final String from;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered by the StreamManagementManager if a stanza has been acked
|
||||||
|
class StanzaAckedEvent extends XmppEvent {
|
||||||
|
StanzaAckedEvent(this.stanza);
|
||||||
|
final Stanza stanza;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when receiving a push of the blocklist
|
||||||
|
class BlocklistBlockPushEvent extends XmppEvent {
|
||||||
|
BlocklistBlockPushEvent({ required this.items });
|
||||||
|
final List<String> items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when receiving a push of the blocklist
|
||||||
|
class BlocklistUnblockPushEvent extends XmppEvent {
|
||||||
|
BlocklistUnblockPushEvent({ required this.items });
|
||||||
|
final List<String> items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when receiving a push of the blocklist
|
||||||
|
class BlocklistUnblockAllPushEvent extends XmppEvent {
|
||||||
|
BlocklistUnblockAllPushEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when a stanza has not been sent because a stanza handler
|
||||||
|
/// wanted to cancel the entire process.
|
||||||
|
class StanzaSendingCancelledEvent extends XmppEvent {
|
||||||
|
StanzaSendingCancelledEvent(this.data);
|
||||||
|
final StanzaHandlerData data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when the device list of a Jid is updated
|
||||||
|
class OmemoDeviceListUpdatedEvent extends XmppEvent {
|
||||||
|
OmemoDeviceListUpdatedEvent(this.jid, this.deviceList);
|
||||||
|
final JID jid;
|
||||||
|
final List<int> deviceList;
|
||||||
|
}
|
10
moxxmpp/lib/src/iq.dart
Normal file
10
moxxmpp/lib/src/iq.dart
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import 'package:moxxmpp/src/connection.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
|
||||||
|
bool handleUnhandledStanza(XmppConnection conn, Stanza stanza) {
|
||||||
|
if (stanza.type != 'error' && stanza.type != 'result') {
|
||||||
|
conn.sendStanza(stanza.errorReply('cancel', 'feature-not-implemented'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
108
moxxmpp/lib/src/jid.dart
Normal file
108
moxxmpp/lib/src/jid.dart
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import 'package:meta/meta.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class JID {
|
||||||
|
|
||||||
|
const JID(this.local, this.domain, this.resource);
|
||||||
|
|
||||||
|
factory JID.fromString(String jid) {
|
||||||
|
// 0: Parsing either the local or domain part
|
||||||
|
// 1: Parsing the domain part
|
||||||
|
// 2: Parsing the resource
|
||||||
|
var state = 0;
|
||||||
|
var buffer = '';
|
||||||
|
var local_ = '';
|
||||||
|
var domain_ = '';
|
||||||
|
var resource_ = '';
|
||||||
|
|
||||||
|
for (var i = 0; i < jid.length; i++) {
|
||||||
|
final c = jid[i];
|
||||||
|
final eol = i == jid.length - 1;
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case 0: {
|
||||||
|
if (c == '@') {
|
||||||
|
local_ = buffer;
|
||||||
|
buffer = '';
|
||||||
|
state = 1;
|
||||||
|
} else if (c == '/') {
|
||||||
|
domain_ = buffer;
|
||||||
|
buffer = '';
|
||||||
|
state = 2;
|
||||||
|
} else if (eol) {
|
||||||
|
domain_ = buffer + c;
|
||||||
|
} else {
|
||||||
|
buffer += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 1: {
|
||||||
|
if (c == '/') {
|
||||||
|
domain_ = buffer;
|
||||||
|
buffer = '';
|
||||||
|
state = 2;
|
||||||
|
} else if (eol) {
|
||||||
|
domain_ = buffer;
|
||||||
|
|
||||||
|
if (c != ' ') {
|
||||||
|
domain_ = domain_ + c;
|
||||||
|
}
|
||||||
|
} else if (c != ' ') {
|
||||||
|
buffer += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 2: {
|
||||||
|
if (eol) {
|
||||||
|
resource_ = buffer;
|
||||||
|
|
||||||
|
if (c != ' ') {
|
||||||
|
resource_ = resource_ + c;
|
||||||
|
}
|
||||||
|
} else if (c != ''){
|
||||||
|
buffer += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JID(local_, domain_, resource_);
|
||||||
|
}
|
||||||
|
final String local;
|
||||||
|
final String domain;
|
||||||
|
final String resource;
|
||||||
|
|
||||||
|
bool isBare() => resource.isEmpty;
|
||||||
|
bool isFull() => resource.isNotEmpty;
|
||||||
|
|
||||||
|
JID toBare() => JID(local, domain, '');
|
||||||
|
JID withResource(String resource) => JID(local, domain, resource);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
var result = '';
|
||||||
|
|
||||||
|
if (local.isNotEmpty) {
|
||||||
|
result += '$local@$domain';
|
||||||
|
} else {
|
||||||
|
result += domain;
|
||||||
|
}
|
||||||
|
if (isFull()) {
|
||||||
|
result += '/$resource';
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (other is JID) {
|
||||||
|
return other.local == local && other.domain == domain && other.resource == resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => local.hashCode ^ domain.hashCode ^ resource.hashCode;
|
||||||
|
}
|
55
moxxmpp/lib/src/managers/attributes.dart
Normal file
55
moxxmpp/lib/src/managers/attributes.dart
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:moxxmpp/src/connection.dart';
|
||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/negotiator.dart';
|
||||||
|
import 'package:moxxmpp/src/settings.dart';
|
||||||
|
import 'package:moxxmpp/src/socket.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
class XmppManagerAttributes {
|
||||||
|
|
||||||
|
XmppManagerAttributes({
|
||||||
|
required this.sendStanza,
|
||||||
|
required this.sendNonza,
|
||||||
|
required this.getManagerById,
|
||||||
|
required this.sendEvent,
|
||||||
|
required this.getConnectionSettings,
|
||||||
|
required this.isFeatureSupported,
|
||||||
|
required this.getFullJID,
|
||||||
|
required this.getSocket,
|
||||||
|
required this.getConnection,
|
||||||
|
required this.getNegotiatorById,
|
||||||
|
});
|
||||||
|
/// Send a stanza whose response can be awaited.
|
||||||
|
final Future<XMLNode> Function(Stanza stanza, { StanzaFromType addFrom, bool addId, bool awaitable, bool encrypted}) sendStanza;
|
||||||
|
|
||||||
|
/// Send a nonza.
|
||||||
|
final void Function(XMLNode) sendNonza;
|
||||||
|
|
||||||
|
/// Send an event to the connection's event channel.
|
||||||
|
final void Function(XmppEvent) sendEvent;
|
||||||
|
|
||||||
|
/// Get the connection settings of the attached connection.
|
||||||
|
final ConnectionSettings Function() getConnectionSettings;
|
||||||
|
|
||||||
|
/// (Maybe) Get a Manager attached to the connection by its Id.
|
||||||
|
final T? Function<T extends XmppManagerBase>(String) getManagerById;
|
||||||
|
|
||||||
|
/// Returns true if a server feature is supported
|
||||||
|
final bool Function(String) isFeatureSupported;
|
||||||
|
|
||||||
|
/// Returns the full JID of the current account
|
||||||
|
final JID Function() getFullJID;
|
||||||
|
|
||||||
|
/// Returns the current socket. MUST NOT be used to send data.
|
||||||
|
final BaseSocketWrapper Function() getSocket;
|
||||||
|
|
||||||
|
/// Return the [XmppConnection] the manager is registered against.
|
||||||
|
final XmppConnection Function() getConnection;
|
||||||
|
|
||||||
|
final T? Function<T extends XmppFeatureNegotiatorBase>(String) getNegotiatorById;
|
||||||
|
}
|
72
moxxmpp/lib/src/managers/base.dart
Normal file
72
moxxmpp/lib/src/managers/base.dart
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/attributes.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
abstract class XmppManagerBase {
|
||||||
|
late final XmppManagerAttributes _managerAttributes;
|
||||||
|
late final Logger _log;
|
||||||
|
|
||||||
|
/// Registers the callbacks from XmppConnection with the manager
|
||||||
|
void register(XmppManagerAttributes attributes) {
|
||||||
|
_managerAttributes = attributes;
|
||||||
|
_log = Logger(getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the attributes that are registered with the manager.
|
||||||
|
/// Must only be called after register has been called on it.
|
||||||
|
XmppManagerAttributes getAttributes() {
|
||||||
|
return _managerAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the StanzaHandlers associated with this manager that deal with stanzas we
|
||||||
|
/// send. These are run before the stanza is sent.
|
||||||
|
List<StanzaHandler> getOutgoingPreStanzaHandlers() => [];
|
||||||
|
|
||||||
|
/// Return the StanzaHandlers associated with this manager that deal with stanzas we
|
||||||
|
/// send. These are run after the stanza is sent.
|
||||||
|
List<StanzaHandler> getOutgoingPostStanzaHandlers() => [];
|
||||||
|
|
||||||
|
/// Return the StanzaHandlers associated with this manager that deal with stanzas we
|
||||||
|
/// receive.
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [];
|
||||||
|
|
||||||
|
/// Return the NonzaHandlers associated with this manager.
|
||||||
|
List<NonzaHandler> getNonzaHandlers() => [];
|
||||||
|
|
||||||
|
/// Return a list of features that should be included in a disco response.
|
||||||
|
List<String> getDiscoFeatures() => [];
|
||||||
|
|
||||||
|
/// Return the Id (akin to xmlns) of this manager.
|
||||||
|
String getId();
|
||||||
|
|
||||||
|
/// Return a name that will be used for logging.
|
||||||
|
String getName();
|
||||||
|
|
||||||
|
/// Return the logger for this manager.
|
||||||
|
Logger get logger => _log;
|
||||||
|
|
||||||
|
/// Called when XmppConnection triggers an event
|
||||||
|
Future<void> onXmppEvent(XmppEvent event) async {}
|
||||||
|
|
||||||
|
/// Returns true if the XEP is supported on the server. If not, returns false
|
||||||
|
Future<bool> isSupported();
|
||||||
|
|
||||||
|
/// Runs all NonzaHandlers of this Manager which match the nonza. Resolves to true if
|
||||||
|
/// the nonza has been handled by one of the handlers. Resolves to false otherwise.
|
||||||
|
Future<bool> runNonzaHandlers(XMLNode nonza) async {
|
||||||
|
var handled = false;
|
||||||
|
await Future.forEach(
|
||||||
|
getNonzaHandlers(),
|
||||||
|
(NonzaHandler handler) async {
|
||||||
|
if (handler.matches(nonza)) {
|
||||||
|
handled = true;
|
||||||
|
await handler.callback(nonza);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return handled;
|
||||||
|
}
|
||||||
|
}
|
60
moxxmpp/lib/src/managers/data.dart
Normal file
60
moxxmpp/lib/src/managers/data.dart
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0066.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0085.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0203.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0359.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0380.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0385.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0446.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0447.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0461.dart';
|
||||||
|
|
||||||
|
part 'data.freezed.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class StanzaHandlerData with _$StanzaHandlerData {
|
||||||
|
factory StanzaHandlerData(
|
||||||
|
// Indicates to the runner that processing is now done. This means that all
|
||||||
|
// pre-processing is done and no other handlers should be consulted.
|
||||||
|
bool done,
|
||||||
|
// Indicates to the runner that processing is to be cancelled and no further handlers
|
||||||
|
// should run. The stanza also will not be sent.
|
||||||
|
bool cancel,
|
||||||
|
// The reason why we cancelled the processing and sending
|
||||||
|
dynamic cancelReason,
|
||||||
|
// The stanza that is being dealt with. SHOULD NOT be overwritten, unless it is absolutely
|
||||||
|
// necessary, e.g. with Message Carbons or OMEMO
|
||||||
|
Stanza stanza,
|
||||||
|
{
|
||||||
|
// Whether the stanza is retransmitted. Only useful in the context of outgoing
|
||||||
|
// stanza handlers. MUST NOT be overwritten.
|
||||||
|
@Default(false) bool retransmitted,
|
||||||
|
StatelessMediaSharingData? sims,
|
||||||
|
StatelessFileSharingData? sfs,
|
||||||
|
OOBData? oob,
|
||||||
|
StableStanzaId? stableId,
|
||||||
|
ReplyData? reply,
|
||||||
|
ChatState? chatState,
|
||||||
|
@Default(false) bool isCarbon,
|
||||||
|
@Default(false) bool deliveryReceiptRequested,
|
||||||
|
@Default(false) bool isMarkable,
|
||||||
|
// File Upload Notifications
|
||||||
|
// A notification
|
||||||
|
FileMetadataData? fun,
|
||||||
|
// The stanza id this replaces
|
||||||
|
String? funReplacement,
|
||||||
|
// The stanza id this cancels
|
||||||
|
String? funCancellation,
|
||||||
|
// Whether the stanza was received encrypted
|
||||||
|
@Default(false) bool encrypted,
|
||||||
|
// The stated type of encryption used, if any was used
|
||||||
|
ExplicitEncryptionType? encryptionType,
|
||||||
|
// Delayed Delivery
|
||||||
|
DelayedDelivery? delayedDelivery,
|
||||||
|
// This is for stanza handlers that are not part of the XMPP library but still need
|
||||||
|
// pass data around.
|
||||||
|
@Default(<String, dynamic>{}) Map<String, dynamic> other,
|
||||||
|
}
|
||||||
|
) = _StanzaHandlerData;
|
||||||
|
}
|
613
moxxmpp/lib/src/managers/data.freezed.dart
Normal file
613
moxxmpp/lib/src/managers/data.freezed.dart
Normal file
@ -0,0 +1,613 @@
|
|||||||
|
// coverage:ignore-file
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target
|
||||||
|
|
||||||
|
part of 'data.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
final _privateConstructorUsedError = UnsupportedError(
|
||||||
|
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$StanzaHandlerData {
|
||||||
|
// Indicates to the runner that processing is now done. This means that all
|
||||||
|
// pre-processing is done and no other handlers should be consulted.
|
||||||
|
bool get done =>
|
||||||
|
throw _privateConstructorUsedError; // Indicates to the runner that processing is to be cancelled and no further handlers
|
||||||
|
// should run. The stanza also will not be sent.
|
||||||
|
bool get cancel =>
|
||||||
|
throw _privateConstructorUsedError; // The reason why we cancelled the processing and sending
|
||||||
|
dynamic get cancelReason =>
|
||||||
|
throw _privateConstructorUsedError; // The stanza that is being dealt with. SHOULD NOT be overwritten, unless it is absolutely
|
||||||
|
// necessary, e.g. with Message Carbons or OMEMO
|
||||||
|
Stanza get stanza =>
|
||||||
|
throw _privateConstructorUsedError; // Whether the stanza is retransmitted. Only useful in the context of outgoing
|
||||||
|
// stanza handlers. MUST NOT be overwritten.
|
||||||
|
bool get retransmitted => throw _privateConstructorUsedError;
|
||||||
|
StatelessMediaSharingData? get sims => throw _privateConstructorUsedError;
|
||||||
|
StatelessFileSharingData? get sfs => throw _privateConstructorUsedError;
|
||||||
|
OOBData? get oob => throw _privateConstructorUsedError;
|
||||||
|
StableStanzaId? get stableId => throw _privateConstructorUsedError;
|
||||||
|
ReplyData? get reply => throw _privateConstructorUsedError;
|
||||||
|
ChatState? get chatState => throw _privateConstructorUsedError;
|
||||||
|
bool get isCarbon => throw _privateConstructorUsedError;
|
||||||
|
bool get deliveryReceiptRequested => throw _privateConstructorUsedError;
|
||||||
|
bool get isMarkable =>
|
||||||
|
throw _privateConstructorUsedError; // File Upload Notifications
|
||||||
|
// A notification
|
||||||
|
FileMetadataData? get fun =>
|
||||||
|
throw _privateConstructorUsedError; // The stanza id this replaces
|
||||||
|
String? get funReplacement =>
|
||||||
|
throw _privateConstructorUsedError; // The stanza id this cancels
|
||||||
|
String? get funCancellation =>
|
||||||
|
throw _privateConstructorUsedError; // Whether the stanza was received encrypted
|
||||||
|
bool get encrypted =>
|
||||||
|
throw _privateConstructorUsedError; // The stated type of encryption used, if any was used
|
||||||
|
ExplicitEncryptionType? get encryptionType =>
|
||||||
|
throw _privateConstructorUsedError; // Delayed Delivery
|
||||||
|
DelayedDelivery? get delayedDelivery =>
|
||||||
|
throw _privateConstructorUsedError; // This is for stanza handlers that are not part of the XMPP library but still need
|
||||||
|
// pass data around.
|
||||||
|
Map<String, dynamic> get other => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
$StanzaHandlerDataCopyWith<StanzaHandlerData> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class $StanzaHandlerDataCopyWith<$Res> {
|
||||||
|
factory $StanzaHandlerDataCopyWith(
|
||||||
|
StanzaHandlerData value, $Res Function(StanzaHandlerData) then) =
|
||||||
|
_$StanzaHandlerDataCopyWithImpl<$Res>;
|
||||||
|
$Res call(
|
||||||
|
{bool done,
|
||||||
|
bool cancel,
|
||||||
|
dynamic cancelReason,
|
||||||
|
Stanza stanza,
|
||||||
|
bool retransmitted,
|
||||||
|
StatelessMediaSharingData? sims,
|
||||||
|
StatelessFileSharingData? sfs,
|
||||||
|
OOBData? oob,
|
||||||
|
StableStanzaId? stableId,
|
||||||
|
ReplyData? reply,
|
||||||
|
ChatState? chatState,
|
||||||
|
bool isCarbon,
|
||||||
|
bool deliveryReceiptRequested,
|
||||||
|
bool isMarkable,
|
||||||
|
FileMetadataData? fun,
|
||||||
|
String? funReplacement,
|
||||||
|
String? funCancellation,
|
||||||
|
bool encrypted,
|
||||||
|
ExplicitEncryptionType? encryptionType,
|
||||||
|
DelayedDelivery? delayedDelivery,
|
||||||
|
Map<String, dynamic> other});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class _$StanzaHandlerDataCopyWithImpl<$Res>
|
||||||
|
implements $StanzaHandlerDataCopyWith<$Res> {
|
||||||
|
_$StanzaHandlerDataCopyWithImpl(this._value, this._then);
|
||||||
|
|
||||||
|
final StanzaHandlerData _value;
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Res Function(StanzaHandlerData) _then;
|
||||||
|
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? done = freezed,
|
||||||
|
Object? cancel = freezed,
|
||||||
|
Object? cancelReason = freezed,
|
||||||
|
Object? stanza = freezed,
|
||||||
|
Object? retransmitted = freezed,
|
||||||
|
Object? sims = freezed,
|
||||||
|
Object? sfs = freezed,
|
||||||
|
Object? oob = freezed,
|
||||||
|
Object? stableId = freezed,
|
||||||
|
Object? reply = freezed,
|
||||||
|
Object? chatState = freezed,
|
||||||
|
Object? isCarbon = freezed,
|
||||||
|
Object? deliveryReceiptRequested = freezed,
|
||||||
|
Object? isMarkable = freezed,
|
||||||
|
Object? fun = freezed,
|
||||||
|
Object? funReplacement = freezed,
|
||||||
|
Object? funCancellation = freezed,
|
||||||
|
Object? encrypted = freezed,
|
||||||
|
Object? encryptionType = freezed,
|
||||||
|
Object? delayedDelivery = freezed,
|
||||||
|
Object? other = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(_value.copyWith(
|
||||||
|
done: done == freezed
|
||||||
|
? _value.done
|
||||||
|
: done // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
cancel: cancel == freezed
|
||||||
|
? _value.cancel
|
||||||
|
: cancel // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
cancelReason: cancelReason == freezed
|
||||||
|
? _value.cancelReason
|
||||||
|
: cancelReason // ignore: cast_nullable_to_non_nullable
|
||||||
|
as dynamic,
|
||||||
|
stanza: stanza == freezed
|
||||||
|
? _value.stanza
|
||||||
|
: stanza // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Stanza,
|
||||||
|
retransmitted: retransmitted == freezed
|
||||||
|
? _value.retransmitted
|
||||||
|
: retransmitted // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
sims: sims == freezed
|
||||||
|
? _value.sims
|
||||||
|
: sims // ignore: cast_nullable_to_non_nullable
|
||||||
|
as StatelessMediaSharingData?,
|
||||||
|
sfs: sfs == freezed
|
||||||
|
? _value.sfs
|
||||||
|
: sfs // ignore: cast_nullable_to_non_nullable
|
||||||
|
as StatelessFileSharingData?,
|
||||||
|
oob: oob == freezed
|
||||||
|
? _value.oob
|
||||||
|
: oob // ignore: cast_nullable_to_non_nullable
|
||||||
|
as OOBData?,
|
||||||
|
stableId: stableId == freezed
|
||||||
|
? _value.stableId
|
||||||
|
: stableId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as StableStanzaId?,
|
||||||
|
reply: reply == freezed
|
||||||
|
? _value.reply
|
||||||
|
: reply // ignore: cast_nullable_to_non_nullable
|
||||||
|
as ReplyData?,
|
||||||
|
chatState: chatState == freezed
|
||||||
|
? _value.chatState
|
||||||
|
: chatState // ignore: cast_nullable_to_non_nullable
|
||||||
|
as ChatState?,
|
||||||
|
isCarbon: isCarbon == freezed
|
||||||
|
? _value.isCarbon
|
||||||
|
: isCarbon // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
deliveryReceiptRequested: deliveryReceiptRequested == freezed
|
||||||
|
? _value.deliveryReceiptRequested
|
||||||
|
: deliveryReceiptRequested // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
isMarkable: isMarkable == freezed
|
||||||
|
? _value.isMarkable
|
||||||
|
: isMarkable // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
fun: fun == freezed
|
||||||
|
? _value.fun
|
||||||
|
: fun // ignore: cast_nullable_to_non_nullable
|
||||||
|
as FileMetadataData?,
|
||||||
|
funReplacement: funReplacement == freezed
|
||||||
|
? _value.funReplacement
|
||||||
|
: funReplacement // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
funCancellation: funCancellation == freezed
|
||||||
|
? _value.funCancellation
|
||||||
|
: funCancellation // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
encrypted: encrypted == freezed
|
||||||
|
? _value.encrypted
|
||||||
|
: encrypted // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
encryptionType: encryptionType == freezed
|
||||||
|
? _value.encryptionType
|
||||||
|
: encryptionType // ignore: cast_nullable_to_non_nullable
|
||||||
|
as ExplicitEncryptionType?,
|
||||||
|
delayedDelivery: delayedDelivery == freezed
|
||||||
|
? _value.delayedDelivery
|
||||||
|
: delayedDelivery // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DelayedDelivery?,
|
||||||
|
other: other == freezed
|
||||||
|
? _value.other
|
||||||
|
: other // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Map<String, dynamic>,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$_StanzaHandlerDataCopyWith<$Res>
|
||||||
|
implements $StanzaHandlerDataCopyWith<$Res> {
|
||||||
|
factory _$$_StanzaHandlerDataCopyWith(_$_StanzaHandlerData value,
|
||||||
|
$Res Function(_$_StanzaHandlerData) then) =
|
||||||
|
__$$_StanzaHandlerDataCopyWithImpl<$Res>;
|
||||||
|
@override
|
||||||
|
$Res call(
|
||||||
|
{bool done,
|
||||||
|
bool cancel,
|
||||||
|
dynamic cancelReason,
|
||||||
|
Stanza stanza,
|
||||||
|
bool retransmitted,
|
||||||
|
StatelessMediaSharingData? sims,
|
||||||
|
StatelessFileSharingData? sfs,
|
||||||
|
OOBData? oob,
|
||||||
|
StableStanzaId? stableId,
|
||||||
|
ReplyData? reply,
|
||||||
|
ChatState? chatState,
|
||||||
|
bool isCarbon,
|
||||||
|
bool deliveryReceiptRequested,
|
||||||
|
bool isMarkable,
|
||||||
|
FileMetadataData? fun,
|
||||||
|
String? funReplacement,
|
||||||
|
String? funCancellation,
|
||||||
|
bool encrypted,
|
||||||
|
ExplicitEncryptionType? encryptionType,
|
||||||
|
DelayedDelivery? delayedDelivery,
|
||||||
|
Map<String, dynamic> other});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$_StanzaHandlerDataCopyWithImpl<$Res>
|
||||||
|
extends _$StanzaHandlerDataCopyWithImpl<$Res>
|
||||||
|
implements _$$_StanzaHandlerDataCopyWith<$Res> {
|
||||||
|
__$$_StanzaHandlerDataCopyWithImpl(
|
||||||
|
_$_StanzaHandlerData _value, $Res Function(_$_StanzaHandlerData) _then)
|
||||||
|
: super(_value, (v) => _then(v as _$_StanzaHandlerData));
|
||||||
|
|
||||||
|
@override
|
||||||
|
_$_StanzaHandlerData get _value => super._value as _$_StanzaHandlerData;
|
||||||
|
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? done = freezed,
|
||||||
|
Object? cancel = freezed,
|
||||||
|
Object? cancelReason = freezed,
|
||||||
|
Object? stanza = freezed,
|
||||||
|
Object? retransmitted = freezed,
|
||||||
|
Object? sims = freezed,
|
||||||
|
Object? sfs = freezed,
|
||||||
|
Object? oob = freezed,
|
||||||
|
Object? stableId = freezed,
|
||||||
|
Object? reply = freezed,
|
||||||
|
Object? chatState = freezed,
|
||||||
|
Object? isCarbon = freezed,
|
||||||
|
Object? deliveryReceiptRequested = freezed,
|
||||||
|
Object? isMarkable = freezed,
|
||||||
|
Object? fun = freezed,
|
||||||
|
Object? funReplacement = freezed,
|
||||||
|
Object? funCancellation = freezed,
|
||||||
|
Object? encrypted = freezed,
|
||||||
|
Object? encryptionType = freezed,
|
||||||
|
Object? delayedDelivery = freezed,
|
||||||
|
Object? other = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(_$_StanzaHandlerData(
|
||||||
|
done == freezed
|
||||||
|
? _value.done
|
||||||
|
: done // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
cancel == freezed
|
||||||
|
? _value.cancel
|
||||||
|
: cancel // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
cancelReason == freezed
|
||||||
|
? _value.cancelReason
|
||||||
|
: cancelReason // ignore: cast_nullable_to_non_nullable
|
||||||
|
as dynamic,
|
||||||
|
stanza == freezed
|
||||||
|
? _value.stanza
|
||||||
|
: stanza // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Stanza,
|
||||||
|
retransmitted: retransmitted == freezed
|
||||||
|
? _value.retransmitted
|
||||||
|
: retransmitted // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
sims: sims == freezed
|
||||||
|
? _value.sims
|
||||||
|
: sims // ignore: cast_nullable_to_non_nullable
|
||||||
|
as StatelessMediaSharingData?,
|
||||||
|
sfs: sfs == freezed
|
||||||
|
? _value.sfs
|
||||||
|
: sfs // ignore: cast_nullable_to_non_nullable
|
||||||
|
as StatelessFileSharingData?,
|
||||||
|
oob: oob == freezed
|
||||||
|
? _value.oob
|
||||||
|
: oob // ignore: cast_nullable_to_non_nullable
|
||||||
|
as OOBData?,
|
||||||
|
stableId: stableId == freezed
|
||||||
|
? _value.stableId
|
||||||
|
: stableId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as StableStanzaId?,
|
||||||
|
reply: reply == freezed
|
||||||
|
? _value.reply
|
||||||
|
: reply // ignore: cast_nullable_to_non_nullable
|
||||||
|
as ReplyData?,
|
||||||
|
chatState: chatState == freezed
|
||||||
|
? _value.chatState
|
||||||
|
: chatState // ignore: cast_nullable_to_non_nullable
|
||||||
|
as ChatState?,
|
||||||
|
isCarbon: isCarbon == freezed
|
||||||
|
? _value.isCarbon
|
||||||
|
: isCarbon // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
deliveryReceiptRequested: deliveryReceiptRequested == freezed
|
||||||
|
? _value.deliveryReceiptRequested
|
||||||
|
: deliveryReceiptRequested // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
isMarkable: isMarkable == freezed
|
||||||
|
? _value.isMarkable
|
||||||
|
: isMarkable // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
fun: fun == freezed
|
||||||
|
? _value.fun
|
||||||
|
: fun // ignore: cast_nullable_to_non_nullable
|
||||||
|
as FileMetadataData?,
|
||||||
|
funReplacement: funReplacement == freezed
|
||||||
|
? _value.funReplacement
|
||||||
|
: funReplacement // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
funCancellation: funCancellation == freezed
|
||||||
|
? _value.funCancellation
|
||||||
|
: funCancellation // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
encrypted: encrypted == freezed
|
||||||
|
? _value.encrypted
|
||||||
|
: encrypted // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
|
encryptionType: encryptionType == freezed
|
||||||
|
? _value.encryptionType
|
||||||
|
: encryptionType // ignore: cast_nullable_to_non_nullable
|
||||||
|
as ExplicitEncryptionType?,
|
||||||
|
delayedDelivery: delayedDelivery == freezed
|
||||||
|
? _value.delayedDelivery
|
||||||
|
: delayedDelivery // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DelayedDelivery?,
|
||||||
|
other: other == freezed
|
||||||
|
? _value._other
|
||||||
|
: other // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Map<String, dynamic>,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
|
||||||
|
class _$_StanzaHandlerData implements _StanzaHandlerData {
|
||||||
|
_$_StanzaHandlerData(this.done, this.cancel, this.cancelReason, this.stanza,
|
||||||
|
{this.retransmitted = false,
|
||||||
|
this.sims,
|
||||||
|
this.sfs,
|
||||||
|
this.oob,
|
||||||
|
this.stableId,
|
||||||
|
this.reply,
|
||||||
|
this.chatState,
|
||||||
|
this.isCarbon = false,
|
||||||
|
this.deliveryReceiptRequested = false,
|
||||||
|
this.isMarkable = false,
|
||||||
|
this.fun,
|
||||||
|
this.funReplacement,
|
||||||
|
this.funCancellation,
|
||||||
|
this.encrypted = false,
|
||||||
|
this.encryptionType,
|
||||||
|
this.delayedDelivery,
|
||||||
|
final Map<String, dynamic> other = const <String, dynamic>{}})
|
||||||
|
: _other = other;
|
||||||
|
|
||||||
|
// Indicates to the runner that processing is now done. This means that all
|
||||||
|
// pre-processing is done and no other handlers should be consulted.
|
||||||
|
@override
|
||||||
|
final bool done;
|
||||||
|
// Indicates to the runner that processing is to be cancelled and no further handlers
|
||||||
|
// should run. The stanza also will not be sent.
|
||||||
|
@override
|
||||||
|
final bool cancel;
|
||||||
|
// The reason why we cancelled the processing and sending
|
||||||
|
@override
|
||||||
|
final dynamic cancelReason;
|
||||||
|
// The stanza that is being dealt with. SHOULD NOT be overwritten, unless it is absolutely
|
||||||
|
// necessary, e.g. with Message Carbons or OMEMO
|
||||||
|
@override
|
||||||
|
final Stanza stanza;
|
||||||
|
// Whether the stanza is retransmitted. Only useful in the context of outgoing
|
||||||
|
// stanza handlers. MUST NOT be overwritten.
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final bool retransmitted;
|
||||||
|
@override
|
||||||
|
final StatelessMediaSharingData? sims;
|
||||||
|
@override
|
||||||
|
final StatelessFileSharingData? sfs;
|
||||||
|
@override
|
||||||
|
final OOBData? oob;
|
||||||
|
@override
|
||||||
|
final StableStanzaId? stableId;
|
||||||
|
@override
|
||||||
|
final ReplyData? reply;
|
||||||
|
@override
|
||||||
|
final ChatState? chatState;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final bool isCarbon;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final bool deliveryReceiptRequested;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final bool isMarkable;
|
||||||
|
// File Upload Notifications
|
||||||
|
// A notification
|
||||||
|
@override
|
||||||
|
final FileMetadataData? fun;
|
||||||
|
// The stanza id this replaces
|
||||||
|
@override
|
||||||
|
final String? funReplacement;
|
||||||
|
// The stanza id this cancels
|
||||||
|
@override
|
||||||
|
final String? funCancellation;
|
||||||
|
// Whether the stanza was received encrypted
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final bool encrypted;
|
||||||
|
// The stated type of encryption used, if any was used
|
||||||
|
@override
|
||||||
|
final ExplicitEncryptionType? encryptionType;
|
||||||
|
// Delayed Delivery
|
||||||
|
@override
|
||||||
|
final DelayedDelivery? delayedDelivery;
|
||||||
|
// This is for stanza handlers that are not part of the XMPP library but still need
|
||||||
|
// pass data around.
|
||||||
|
final Map<String, dynamic> _other;
|
||||||
|
// This is for stanza handlers that are not part of the XMPP library but still need
|
||||||
|
// pass data around.
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
Map<String, dynamic> get other {
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableMapView(_other);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'StanzaHandlerData(done: $done, cancel: $cancel, cancelReason: $cancelReason, stanza: $stanza, retransmitted: $retransmitted, sims: $sims, sfs: $sfs, oob: $oob, stableId: $stableId, reply: $reply, chatState: $chatState, isCarbon: $isCarbon, deliveryReceiptRequested: $deliveryReceiptRequested, isMarkable: $isMarkable, fun: $fun, funReplacement: $funReplacement, funCancellation: $funCancellation, encrypted: $encrypted, encryptionType: $encryptionType, delayedDelivery: $delayedDelivery, other: $other)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(dynamic other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$_StanzaHandlerData &&
|
||||||
|
const DeepCollectionEquality().equals(other.done, done) &&
|
||||||
|
const DeepCollectionEquality().equals(other.cancel, cancel) &&
|
||||||
|
const DeepCollectionEquality()
|
||||||
|
.equals(other.cancelReason, cancelReason) &&
|
||||||
|
const DeepCollectionEquality().equals(other.stanza, stanza) &&
|
||||||
|
const DeepCollectionEquality()
|
||||||
|
.equals(other.retransmitted, retransmitted) &&
|
||||||
|
const DeepCollectionEquality().equals(other.sims, sims) &&
|
||||||
|
const DeepCollectionEquality().equals(other.sfs, sfs) &&
|
||||||
|
const DeepCollectionEquality().equals(other.oob, oob) &&
|
||||||
|
const DeepCollectionEquality().equals(other.stableId, stableId) &&
|
||||||
|
const DeepCollectionEquality().equals(other.reply, reply) &&
|
||||||
|
const DeepCollectionEquality().equals(other.chatState, chatState) &&
|
||||||
|
const DeepCollectionEquality().equals(other.isCarbon, isCarbon) &&
|
||||||
|
const DeepCollectionEquality().equals(
|
||||||
|
other.deliveryReceiptRequested, deliveryReceiptRequested) &&
|
||||||
|
const DeepCollectionEquality()
|
||||||
|
.equals(other.isMarkable, isMarkable) &&
|
||||||
|
const DeepCollectionEquality().equals(other.fun, fun) &&
|
||||||
|
const DeepCollectionEquality()
|
||||||
|
.equals(other.funReplacement, funReplacement) &&
|
||||||
|
const DeepCollectionEquality()
|
||||||
|
.equals(other.funCancellation, funCancellation) &&
|
||||||
|
const DeepCollectionEquality().equals(other.encrypted, encrypted) &&
|
||||||
|
const DeepCollectionEquality()
|
||||||
|
.equals(other.encryptionType, encryptionType) &&
|
||||||
|
const DeepCollectionEquality()
|
||||||
|
.equals(other.delayedDelivery, delayedDelivery) &&
|
||||||
|
const DeepCollectionEquality().equals(other._other, this._other));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hashAll([
|
||||||
|
runtimeType,
|
||||||
|
const DeepCollectionEquality().hash(done),
|
||||||
|
const DeepCollectionEquality().hash(cancel),
|
||||||
|
const DeepCollectionEquality().hash(cancelReason),
|
||||||
|
const DeepCollectionEquality().hash(stanza),
|
||||||
|
const DeepCollectionEquality().hash(retransmitted),
|
||||||
|
const DeepCollectionEquality().hash(sims),
|
||||||
|
const DeepCollectionEquality().hash(sfs),
|
||||||
|
const DeepCollectionEquality().hash(oob),
|
||||||
|
const DeepCollectionEquality().hash(stableId),
|
||||||
|
const DeepCollectionEquality().hash(reply),
|
||||||
|
const DeepCollectionEquality().hash(chatState),
|
||||||
|
const DeepCollectionEquality().hash(isCarbon),
|
||||||
|
const DeepCollectionEquality().hash(deliveryReceiptRequested),
|
||||||
|
const DeepCollectionEquality().hash(isMarkable),
|
||||||
|
const DeepCollectionEquality().hash(fun),
|
||||||
|
const DeepCollectionEquality().hash(funReplacement),
|
||||||
|
const DeepCollectionEquality().hash(funCancellation),
|
||||||
|
const DeepCollectionEquality().hash(encrypted),
|
||||||
|
const DeepCollectionEquality().hash(encryptionType),
|
||||||
|
const DeepCollectionEquality().hash(delayedDelivery),
|
||||||
|
const DeepCollectionEquality().hash(_other)
|
||||||
|
]);
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
@override
|
||||||
|
_$$_StanzaHandlerDataCopyWith<_$_StanzaHandlerData> get copyWith =>
|
||||||
|
__$$_StanzaHandlerDataCopyWithImpl<_$_StanzaHandlerData>(
|
||||||
|
this, _$identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _StanzaHandlerData implements StanzaHandlerData {
|
||||||
|
factory _StanzaHandlerData(final bool done, final bool cancel,
|
||||||
|
final dynamic cancelReason, final Stanza stanza,
|
||||||
|
{final bool retransmitted,
|
||||||
|
final StatelessMediaSharingData? sims,
|
||||||
|
final StatelessFileSharingData? sfs,
|
||||||
|
final OOBData? oob,
|
||||||
|
final StableStanzaId? stableId,
|
||||||
|
final ReplyData? reply,
|
||||||
|
final ChatState? chatState,
|
||||||
|
final bool isCarbon,
|
||||||
|
final bool deliveryReceiptRequested,
|
||||||
|
final bool isMarkable,
|
||||||
|
final FileMetadataData? fun,
|
||||||
|
final String? funReplacement,
|
||||||
|
final String? funCancellation,
|
||||||
|
final bool encrypted,
|
||||||
|
final ExplicitEncryptionType? encryptionType,
|
||||||
|
final DelayedDelivery? delayedDelivery,
|
||||||
|
final Map<String, dynamic> other}) = _$_StanzaHandlerData;
|
||||||
|
|
||||||
|
@override // Indicates to the runner that processing is now done. This means that all
|
||||||
|
// pre-processing is done and no other handlers should be consulted.
|
||||||
|
bool get done;
|
||||||
|
@override // Indicates to the runner that processing is to be cancelled and no further handlers
|
||||||
|
// should run. The stanza also will not be sent.
|
||||||
|
bool get cancel;
|
||||||
|
@override // The reason why we cancelled the processing and sending
|
||||||
|
dynamic get cancelReason;
|
||||||
|
@override // The stanza that is being dealt with. SHOULD NOT be overwritten, unless it is absolutely
|
||||||
|
// necessary, e.g. with Message Carbons or OMEMO
|
||||||
|
Stanza get stanza;
|
||||||
|
@override // Whether the stanza is retransmitted. Only useful in the context of outgoing
|
||||||
|
// stanza handlers. MUST NOT be overwritten.
|
||||||
|
bool get retransmitted;
|
||||||
|
@override
|
||||||
|
StatelessMediaSharingData? get sims;
|
||||||
|
@override
|
||||||
|
StatelessFileSharingData? get sfs;
|
||||||
|
@override
|
||||||
|
OOBData? get oob;
|
||||||
|
@override
|
||||||
|
StableStanzaId? get stableId;
|
||||||
|
@override
|
||||||
|
ReplyData? get reply;
|
||||||
|
@override
|
||||||
|
ChatState? get chatState;
|
||||||
|
@override
|
||||||
|
bool get isCarbon;
|
||||||
|
@override
|
||||||
|
bool get deliveryReceiptRequested;
|
||||||
|
@override
|
||||||
|
bool get isMarkable;
|
||||||
|
@override // File Upload Notifications
|
||||||
|
// A notification
|
||||||
|
FileMetadataData? get fun;
|
||||||
|
@override // The stanza id this replaces
|
||||||
|
String? get funReplacement;
|
||||||
|
@override // The stanza id this cancels
|
||||||
|
String? get funCancellation;
|
||||||
|
@override // Whether the stanza was received encrypted
|
||||||
|
bool get encrypted;
|
||||||
|
@override // The stated type of encryption used, if any was used
|
||||||
|
ExplicitEncryptionType? get encryptionType;
|
||||||
|
@override // Delayed Delivery
|
||||||
|
DelayedDelivery? get delayedDelivery;
|
||||||
|
@override // This is for stanza handlers that are not part of the XMPP library but still need
|
||||||
|
// pass data around.
|
||||||
|
Map<String, dynamic> get other;
|
||||||
|
@override
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
_$$_StanzaHandlerDataCopyWith<_$_StanzaHandlerData> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
93
moxxmpp/lib/src/managers/handlers.dart
Normal file
93
moxxmpp/lib/src/managers/handlers.dart
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
abstract class Handler {
|
||||||
|
|
||||||
|
const Handler(this.matchStanzas, { this.nonzaTag, this.nonzaXmlns });
|
||||||
|
final String? nonzaTag;
|
||||||
|
final String? nonzaXmlns;
|
||||||
|
final bool matchStanzas;
|
||||||
|
|
||||||
|
/// Returns true if the node matches the description provided by this [Handler].
|
||||||
|
bool matches(XMLNode node) {
|
||||||
|
var matches = false;
|
||||||
|
|
||||||
|
if (nonzaTag == null && nonzaXmlns == null) {
|
||||||
|
matches = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nonzaXmlns != null && nonzaTag != null) {
|
||||||
|
matches = (node.attributes['xmlns'] ?? '') == nonzaXmlns! && node.tag == nonzaTag!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchStanzas && nonzaTag == null) {
|
||||||
|
matches = [ 'iq', 'presence', 'message' ].contains(node.tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NonzaHandler extends Handler {
|
||||||
|
|
||||||
|
NonzaHandler({
|
||||||
|
required this.callback,
|
||||||
|
String? nonzaTag,
|
||||||
|
String? nonzaXmlns,
|
||||||
|
}) : super(
|
||||||
|
false,
|
||||||
|
nonzaTag: nonzaTag,
|
||||||
|
nonzaXmlns: nonzaXmlns,
|
||||||
|
);
|
||||||
|
final Future<bool> Function(XMLNode) callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StanzaHandler extends Handler {
|
||||||
|
|
||||||
|
StanzaHandler({
|
||||||
|
required this.callback,
|
||||||
|
this.tagXmlns,
|
||||||
|
this.tagName,
|
||||||
|
this.priority = 0,
|
||||||
|
String? stanzaTag,
|
||||||
|
}) : super(
|
||||||
|
true,
|
||||||
|
nonzaTag: stanzaTag,
|
||||||
|
nonzaXmlns: stanzaXmlns,
|
||||||
|
);
|
||||||
|
final String? tagName;
|
||||||
|
final String? tagXmlns;
|
||||||
|
final int priority;
|
||||||
|
final Future<StanzaHandlerData> Function(Stanza, StanzaHandlerData) callback;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool matches(XMLNode node) {
|
||||||
|
var matches = super.matches(node);
|
||||||
|
|
||||||
|
if (matches == false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagName != null) {
|
||||||
|
final firstTag = node.firstTag(tagName!, xmlns: tagXmlns);
|
||||||
|
|
||||||
|
matches = firstTag != null;
|
||||||
|
} else if (tagXmlns != null) {
|
||||||
|
return listContains(
|
||||||
|
node.children,
|
||||||
|
(XMLNode _node) => _node.attributes.containsKey('xmlns') && _node.attributes['xmlns'] == tagXmlns,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagName == null && tagXmlns == null) {
|
||||||
|
matches = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int stanzaHandlerSortComparator(StanzaHandler a, StanzaHandler b) => b.priority.compareTo(a.priority);
|
26
moxxmpp/lib/src/managers/namespaces.dart
Normal file
26
moxxmpp/lib/src/managers/namespaces.dart
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
const smManager = 'im.moxxy.streammangementmanager';
|
||||||
|
const discoManager = 'im.moxxy.discomanager';
|
||||||
|
const messageManager = 'im.moxxy.messagemanager';
|
||||||
|
const rosterManager = 'im.moxxy.rostermanager';
|
||||||
|
const presenceManager = 'im.moxxy.presencemanager';
|
||||||
|
const csiManager = 'im.moxxy.csimanager';
|
||||||
|
const carbonsManager = 'im.moxxy.carbonsmanager';
|
||||||
|
const vcardManager = 'im.moxxy.vcardmanager';
|
||||||
|
const pubsubManager = 'im.moxxy.pubsubmanager';
|
||||||
|
const userAvatarManager = 'im.moxxy.useravatarmanager';
|
||||||
|
const stableIdManager = 'im.moxxy.stableidmanager';
|
||||||
|
const simsManager = 'im.moxxy.simsmanager';
|
||||||
|
const messageDeliveryReceiptManager = 'im.moxxy.messagedeliveryreceiptmanager';
|
||||||
|
const chatMarkerManager = 'im.moxxy.chatmarkermanager';
|
||||||
|
const oobManager = 'im.moxxy.oobmanager';
|
||||||
|
const sfsManager = 'im.moxxy.sfsmanager';
|
||||||
|
const messageRepliesManager = 'im.moxxy.messagerepliesmanager';
|
||||||
|
const blockingManager = 'im.moxxy.blockingmanager';
|
||||||
|
const httpFileUploadManager = 'im.moxxy.httpfileuploadmanager';
|
||||||
|
const chatStateManager = 'im.moxxy.chatstatemanager';
|
||||||
|
const pingManager = 'im.moxxy.ping';
|
||||||
|
const fileUploadNotificationManager = 'im.moxxy.fileuploadnotificationmanager';
|
||||||
|
const omemoManager = 'org.moxxy.omemomanager';
|
||||||
|
const emeManager = 'org.moxxy.ememanager';
|
||||||
|
const cryptographicHashManager = 'org.moxxy.cryptographichashmanager';
|
||||||
|
const delayedDeliveryManager = 'org.moxxy.delayeddeliverymanager';
|
0
moxxmpp/lib/src/managers/priorities.dart
Normal file
0
moxxmpp/lib/src/managers/priorities.dart
Normal file
223
moxxmpp/lib/src/message.dart
Normal file
223
moxxmpp/lib/src/message.dart
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/staging/file_upload_notification.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0066.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0085.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0184.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0333.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0359.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0446.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0447.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0448.dart';
|
||||||
|
|
||||||
|
class MessageDetails {
|
||||||
|
|
||||||
|
const MessageDetails({
|
||||||
|
required this.to,
|
||||||
|
this.body,
|
||||||
|
this.requestDeliveryReceipt = false,
|
||||||
|
this.requestChatMarkers = true,
|
||||||
|
this.id,
|
||||||
|
this.originId,
|
||||||
|
this.quoteBody,
|
||||||
|
this.quoteId,
|
||||||
|
this.quoteFrom,
|
||||||
|
this.chatState,
|
||||||
|
this.sfs,
|
||||||
|
this.fun,
|
||||||
|
this.funReplacement,
|
||||||
|
this.funCancellation,
|
||||||
|
this.shouldEncrypt = false,
|
||||||
|
});
|
||||||
|
final String to;
|
||||||
|
final String? body;
|
||||||
|
final bool requestDeliveryReceipt;
|
||||||
|
final bool requestChatMarkers;
|
||||||
|
final String? id;
|
||||||
|
final String? originId;
|
||||||
|
final String? quoteBody;
|
||||||
|
final String? quoteId;
|
||||||
|
final String? quoteFrom;
|
||||||
|
final ChatState? chatState;
|
||||||
|
final StatelessFileSharingData? sfs;
|
||||||
|
final FileMetadataData? fun;
|
||||||
|
final String? funReplacement;
|
||||||
|
final String? funCancellation;
|
||||||
|
final bool shouldEncrypt;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageManager extends XmppManagerBase {
|
||||||
|
@override
|
||||||
|
String getId() => messageManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'MessageManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
callback: _onMessage,
|
||||||
|
priority: -100,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onMessage(Stanza _, StanzaHandlerData state) async {
|
||||||
|
final message = state.stanza;
|
||||||
|
final body = message.firstTag('body');
|
||||||
|
|
||||||
|
getAttributes().sendEvent(MessageEvent(
|
||||||
|
body: body != null ? body.innerText() : '',
|
||||||
|
fromJid: JID.fromString(message.attributes['from']! as String),
|
||||||
|
toJid: JID.fromString(message.attributes['to']! as String),
|
||||||
|
sid: message.attributes['id']! as String,
|
||||||
|
stanzaId: state.stableId ?? const StableStanzaId(),
|
||||||
|
isCarbon: state.isCarbon,
|
||||||
|
deliveryReceiptRequested: state.deliveryReceiptRequested,
|
||||||
|
isMarkable: state.isMarkable,
|
||||||
|
type: message.attributes['type'] as String?,
|
||||||
|
oob: state.oob,
|
||||||
|
sfs: state.sfs,
|
||||||
|
sims: state.sims,
|
||||||
|
reply: state.reply,
|
||||||
|
chatState: state.chatState,
|
||||||
|
fun: state.fun,
|
||||||
|
funReplacement: state.funReplacement,
|
||||||
|
funCancellation: state.funCancellation,
|
||||||
|
encrypted: state.encrypted,
|
||||||
|
other: state.other,
|
||||||
|
),);
|
||||||
|
|
||||||
|
return state.copyWith(done: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a message to to with the content body. If deliveryRequest is true, then
|
||||||
|
/// the message will also request a delivery receipt from the receiver.
|
||||||
|
/// If id is non-null, then it will be the id of the message stanza.
|
||||||
|
/// element to this id. If originId is non-null, then it will create an "origin-id"
|
||||||
|
/// child in the message stanza and set its id to originId.
|
||||||
|
void sendMessage(MessageDetails details) {
|
||||||
|
final stanza = Stanza.message(
|
||||||
|
to: details.to,
|
||||||
|
type: 'chat',
|
||||||
|
id: details.id,
|
||||||
|
children: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (details.quoteBody != null) {
|
||||||
|
final fallback = '> ${details.quoteBody!}';
|
||||||
|
|
||||||
|
stanza
|
||||||
|
..addChild(
|
||||||
|
XMLNode(tag: 'body', text: '$fallback\n${details.body}'),
|
||||||
|
)
|
||||||
|
..addChild(
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'reply',
|
||||||
|
xmlns: replyXmlns,
|
||||||
|
attributes: {
|
||||||
|
'to': details.quoteFrom!,
|
||||||
|
'id': details.quoteId!
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..addChild(
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'fallback',
|
||||||
|
xmlns: fallbackXmlns,
|
||||||
|
attributes: {
|
||||||
|
'for': replyXmlns
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'body',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'start': '0',
|
||||||
|
'end': '${fallback.length}'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
var body = details.body;
|
||||||
|
if (details.sfs != null) {
|
||||||
|
// TODO(Unknown): Maybe find a better solution
|
||||||
|
final firstSource = details.sfs!.sources.first;
|
||||||
|
if (firstSource is StatelessFileSharingUrlSource) {
|
||||||
|
body = firstSource.url;
|
||||||
|
} else if (firstSource is StatelessFileSharingEncryptedSource) {
|
||||||
|
body = firstSource.source.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stanza.addChild(
|
||||||
|
XMLNode(tag: 'body', text: body),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.requestDeliveryReceipt) {
|
||||||
|
stanza.addChild(makeMessageDeliveryRequest());
|
||||||
|
}
|
||||||
|
if (details.requestChatMarkers) {
|
||||||
|
stanza.addChild(makeChatMarkerMarkable());
|
||||||
|
}
|
||||||
|
if (details.originId != null) {
|
||||||
|
stanza.addChild(makeOriginIdElement(details.originId!));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.sfs != null) {
|
||||||
|
stanza.addChild(details.sfs!.toXML());
|
||||||
|
|
||||||
|
final source = details.sfs!.sources.first;
|
||||||
|
if (source is StatelessFileSharingUrlSource) {
|
||||||
|
// SFS recommends OOB as a fallback
|
||||||
|
stanza.addChild(constructOOBNode(OOBData(url: source.url)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.chatState != null) {
|
||||||
|
stanza.addChild(
|
||||||
|
// TODO(Unknown): Move this into xep_0085.dart
|
||||||
|
XMLNode.xmlns(tag: chatStateToString(details.chatState!), xmlns: chatStateXmlns),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.fun != null) {
|
||||||
|
stanza.addChild(
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'file-upload',
|
||||||
|
xmlns: fileUploadNotificationXmlns,
|
||||||
|
children: [
|
||||||
|
details.fun!.toXML(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.funReplacement != null) {
|
||||||
|
stanza.addChild(
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'replaces',
|
||||||
|
xmlns: fileUploadNotificationXmlns,
|
||||||
|
attributes: <String, String>{
|
||||||
|
'id': details.funReplacement!,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAttributes().sendStanza(stanza, awaitable: false);
|
||||||
|
}
|
||||||
|
}
|
134
moxxmpp/lib/src/namespaces.dart
Normal file
134
moxxmpp/lib/src/namespaces.dart
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
// RFC 6120
|
||||||
|
const saslXmlns = 'urn:ietf:params:xml:ns:xmpp-sasl';
|
||||||
|
const stanzaXmlns = 'jabber:client';
|
||||||
|
const streamXmlns = 'http://etherx.jabber.org/streams';
|
||||||
|
const bindXmlns = 'urn:ietf:params:xml:ns:xmpp-bind';
|
||||||
|
const startTlsXmlns = 'urn:ietf:params:xml:ns:xmpp-tls';
|
||||||
|
const fullStanzaXmlns = 'urn:ietf:params:xml:ns:xmpp-stanzas';
|
||||||
|
|
||||||
|
// RFC 6121
|
||||||
|
const rosterXmlns = 'jabber:iq:roster';
|
||||||
|
const rosterVersioningXmlns = 'urn:xmpp:features:rosterver';
|
||||||
|
|
||||||
|
// XEP-0004
|
||||||
|
const dataFormsXmlns = 'jabber:x:data';
|
||||||
|
|
||||||
|
// XEP-0030
|
||||||
|
const discoInfoXmlns = 'http://jabber.org/protocol/disco#info';
|
||||||
|
const discoItemsXmlns = 'http://jabber.org/protocol/disco#items';
|
||||||
|
|
||||||
|
// XEP-0033
|
||||||
|
const extendedAddressingXmlns = 'http://jabber.org/protocol/address';
|
||||||
|
|
||||||
|
// XEP-0054
|
||||||
|
const vCardTempXmlns = 'vcard-temp';
|
||||||
|
const vCardTempUpdate = 'vcard-temp:x:update';
|
||||||
|
|
||||||
|
// XEP-0060
|
||||||
|
const pubsubXmlns = 'http://jabber.org/protocol/pubsub';
|
||||||
|
const pubsubEventXmlns = 'http://jabber.org/protocol/pubsub#event';
|
||||||
|
const pubsubOwnerXmlns = 'http://jabber.org/protocol/pubsub#owner';
|
||||||
|
const pubsubPublishOptionsXmlns = 'http://jabber.org/protocol/pubsub#publish-options';
|
||||||
|
const pubsubNodeConfigMax = 'http://jabber.org/protocol/pubsub#config-node-max';
|
||||||
|
const pubsubNodeConfigMultiItems = 'http://jabber.org/protocol/pubsub#multi-items';
|
||||||
|
|
||||||
|
// XEP-0066
|
||||||
|
const oobDataXmlns = 'jabber:x:oob';
|
||||||
|
|
||||||
|
// XEP-0084
|
||||||
|
const userAvatarDataXmlns = 'urn:xmpp:avatar:data';
|
||||||
|
const userAvatarMetadataXmlns = 'urn:xmpp:avatar:metadata';
|
||||||
|
|
||||||
|
// XEP-0085
|
||||||
|
const chatStateXmlns = 'http://jabber.org/protocol/chatstates';
|
||||||
|
|
||||||
|
// XEP-0115
|
||||||
|
const capsXmlns = 'http://jabber.org/protocol/caps';
|
||||||
|
|
||||||
|
// XEP-0184
|
||||||
|
const deliveryXmlns = 'urn:xmpp:receipts';
|
||||||
|
|
||||||
|
// XEP-0191
|
||||||
|
const blockingXmlns = 'urn:xmpp:blocking';
|
||||||
|
|
||||||
|
// XEP-0198
|
||||||
|
const smXmlns = 'urn:xmpp:sm:3';
|
||||||
|
|
||||||
|
// XEP-0203
|
||||||
|
const delayedDeliveryXmlns = 'urn:xmpp:delay';
|
||||||
|
|
||||||
|
// XEP-0234
|
||||||
|
const jingleFileTransferXmlns = 'urn:xmpp:jingle:apps:file-transfer:5';
|
||||||
|
|
||||||
|
// XEP-0280
|
||||||
|
const carbonsXmlns = 'urn:xmpp:carbons:2';
|
||||||
|
|
||||||
|
// XEP-0297
|
||||||
|
const forwardedXmlns = 'urn:xmpp:forward:0';
|
||||||
|
|
||||||
|
// XEP-0300
|
||||||
|
const hashXmlns = 'urn:xmpp:hashes:2';
|
||||||
|
const hashFunctionNameBaseXmlns = 'urn:xmpp:hash-function-text-names';
|
||||||
|
const hashSha256 = 'sha-256';
|
||||||
|
const hashSha512 = 'sha-512';
|
||||||
|
const hashSha3256 = 'sha3-256';
|
||||||
|
const hashSha3512 = 'sha3-512';
|
||||||
|
const hashBlake2b256 = 'blake2b-256';
|
||||||
|
const hashBlake2b512 = 'blake2b-512';
|
||||||
|
|
||||||
|
// XEP-0333
|
||||||
|
const chatMarkersXmlns = 'urn:xmpp:chat-markers:0';
|
||||||
|
|
||||||
|
// XEP-0334
|
||||||
|
const messageProcessingHintsXmlns = 'urn:xmpp:hints';
|
||||||
|
|
||||||
|
// XEP-0352
|
||||||
|
const csiXmlns = 'urn:xmpp:csi:0';
|
||||||
|
|
||||||
|
// XEP-0359
|
||||||
|
const stableIdXmlns = 'urn:xmpp:sid:0';
|
||||||
|
|
||||||
|
// XEP-0363
|
||||||
|
const httpFileUploadXmlns = 'urn:xmpp:http:upload:0';
|
||||||
|
|
||||||
|
// XEP-0372
|
||||||
|
const referenceXmlns = 'urn:xmpp:reference:0';
|
||||||
|
|
||||||
|
// XEP-380
|
||||||
|
const emeXmlns = 'urn:xmpp:eme:0';
|
||||||
|
const emeOtr = 'urn:xmpp:otr:0';
|
||||||
|
const emeLegacyOpenPGP = 'jabber:x:encrypted';
|
||||||
|
const emeOpenPGP = 'urn:xmpp:openpgp:0';
|
||||||
|
const emeOmemo = 'eu.siacs.conversations.axolotl';
|
||||||
|
const emeOmemo1 = 'urn:xmpp:omemo:1';
|
||||||
|
const emeOmemo2 = 'urn:xmpp:omemo:2';
|
||||||
|
|
||||||
|
// XEP-0384
|
||||||
|
const omemoXmlns = 'urn:xmpp:omemo:2';
|
||||||
|
const omemoDevicesXmlns = 'urn:xmpp:omemo:2:devices';
|
||||||
|
const omemoBundlesXmlns = 'urn:xmpp:omemo:2:bundles';
|
||||||
|
|
||||||
|
// XEP-0385
|
||||||
|
const simsXmlns = 'urn:xmpp:sims:1';
|
||||||
|
|
||||||
|
// XEP-0420
|
||||||
|
const sceXmlns = 'urn:xmpp:sce:1';
|
||||||
|
|
||||||
|
// XEP-0446
|
||||||
|
const fileMetadataXmlns = 'urn:xmpp:file:metadata:0';
|
||||||
|
|
||||||
|
// XEP-0447
|
||||||
|
const sfsXmlns = 'urn:xmpp:sfs:0';
|
||||||
|
|
||||||
|
// XEP-0448
|
||||||
|
const sfsEncryptionXmlns = 'urn:xmpp:esfs:0';
|
||||||
|
const sfsEncryptionAes128GcmNoPaddingXmlns = 'urn:xmpp:ciphers:aes-128-gcm-nopadding:0';
|
||||||
|
const sfsEncryptionAes256GcmNoPaddingXmlns = 'urn:xmpp:ciphers:aes-256-gcm-nopadding:0';
|
||||||
|
const sfsEncryptionAes256CbcPkcs7Xmlns = 'urn:xmpp:ciphers:aes-256-cbc-pkcs7:0';
|
||||||
|
|
||||||
|
// XEP-0461
|
||||||
|
const replyXmlns = 'urn:xmpp:reply:0';
|
||||||
|
const fallbackXmlns = 'urn:xmpp:feature-fallback:0';
|
||||||
|
|
||||||
|
// ???
|
||||||
|
const urlDataXmlns = 'http://jabber.org/protocol/url-data';
|
0
moxxmpp/lib/src/negotiators/manager.dart
Normal file
0
moxxmpp/lib/src/negotiators/manager.dart
Normal file
9
moxxmpp/lib/src/negotiators/namespaces.dart
Normal file
9
moxxmpp/lib/src/negotiators/namespaces.dart
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
const saslPlainNegotiator = 'im.moxxy.sasl.plain';
|
||||||
|
const saslScramSha1Negotiator = 'im.moxxy.sasl.scram.sha1';
|
||||||
|
const saslScramSha256Negotiator = 'im.moxxy.sasl.scram.sha256';
|
||||||
|
const saslScramSha512Negotiator = 'im.moxxy.sasl.scram.sha512';
|
||||||
|
const csiNegotiator = 'im.moxxy.xeps.csi';
|
||||||
|
const rosterNegotiator = 'im.moxxy.core.roster';
|
||||||
|
const resourceBindingNegotiator = 'im.moxxy.core.resource';
|
||||||
|
const streamManagementNegotiator = 'im.moxxy.xeps.sm';
|
||||||
|
const startTlsNegotiator = 'im.moxxy.core.starttls';
|
108
moxxmpp/lib/src/negotiators/negotiator.dart
Normal file
108
moxxmpp/lib/src/negotiators/negotiator.dart
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/settings.dart';
|
||||||
|
import 'package:moxxmpp/src/socket.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
/// The state a negotiator is currently in
|
||||||
|
enum NegotiatorState {
|
||||||
|
// Ready to negotiate the feature
|
||||||
|
ready,
|
||||||
|
// Feature negotiated; negotiator must not be used again
|
||||||
|
done,
|
||||||
|
// Cancel the current attempt but we are not done
|
||||||
|
retryLater,
|
||||||
|
// The negotiator is in an error state
|
||||||
|
error,
|
||||||
|
// Skip the rest of the negotiation and assume the stream ready. Only use this when
|
||||||
|
// using stream restoration XEPs, like Stream Management.
|
||||||
|
skipRest,
|
||||||
|
}
|
||||||
|
|
||||||
|
class NegotiatorAttributes {
|
||||||
|
|
||||||
|
const NegotiatorAttributes(
|
||||||
|
this.sendNonza,
|
||||||
|
this.getConnectionSettings,
|
||||||
|
this.sendEvent,
|
||||||
|
this.getNegotiatorById,
|
||||||
|
this.getManagerById,
|
||||||
|
this.getFullJID,
|
||||||
|
this.getSocket,
|
||||||
|
this.isAuthenticated,
|
||||||
|
);
|
||||||
|
/// Sends the nonza nonza and optionally redacts it in logs if redact is not null.
|
||||||
|
final void Function(XMLNode nonza, {String? redact}) sendNonza;
|
||||||
|
/// Returns the connection settings.
|
||||||
|
final ConnectionSettings Function() getConnectionSettings;
|
||||||
|
/// Send an event event to the connection's event bus
|
||||||
|
final Future<void> Function(XmppEvent event) sendEvent;
|
||||||
|
/// Returns the negotiator with id id of the connection or null.
|
||||||
|
final T? Function<T extends XmppFeatureNegotiatorBase>(String) getNegotiatorById;
|
||||||
|
/// Returns the manager with id id of the connection or null.
|
||||||
|
final T? Function<T extends XmppManagerBase>(String) getManagerById;
|
||||||
|
/// Returns the full JID of the current account
|
||||||
|
final JID Function() getFullJID;
|
||||||
|
/// Returns the socket the negotiator is attached to
|
||||||
|
final BaseSocketWrapper Function() getSocket;
|
||||||
|
/// Returns true if the stream is authenticated. Returns false if not.
|
||||||
|
final bool Function() isAuthenticated;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class XmppFeatureNegotiatorBase {
|
||||||
|
|
||||||
|
XmppFeatureNegotiatorBase(this.priority, this.sendStreamHeaderWhenDone, this.negotiatingXmlns, this.id)
|
||||||
|
: state = NegotiatorState.ready;
|
||||||
|
/// The priority regarding other negotiators. The higher, the earlier will the
|
||||||
|
/// negotiator be used
|
||||||
|
final int priority;
|
||||||
|
|
||||||
|
/// If true, then a new stream header will be sent when the negotiator switches its
|
||||||
|
/// state to done. If false, no stream header will be sent.
|
||||||
|
final bool sendStreamHeaderWhenDone;
|
||||||
|
|
||||||
|
/// The XMLNS the negotiator will negotiate
|
||||||
|
final String negotiatingXmlns;
|
||||||
|
|
||||||
|
/// The Id of the negotiator
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// The state the negotiator is currently in
|
||||||
|
NegotiatorState state;
|
||||||
|
|
||||||
|
late NegotiatorAttributes _attributes;
|
||||||
|
|
||||||
|
/// Register the negotiator against a connection class by means of [attributes].
|
||||||
|
void register(NegotiatorAttributes attributes) {
|
||||||
|
_attributes = attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if a feature in [features], which are the children of the
|
||||||
|
/// <stream:features /> nonza, can be negotiated. Otherwise, returns false.
|
||||||
|
bool matchesFeature(List<XMLNode> features) {
|
||||||
|
return firstWhereOrNull(
|
||||||
|
features,
|
||||||
|
(XMLNode feature) => feature.attributes['xmlns'] == negotiatingXmlns,
|
||||||
|
) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called with the currently received nonza [nonza] when the negotiator is active.
|
||||||
|
/// If the negotiator is just elected to be the next one, then [nonza] is equal to
|
||||||
|
/// the <stream:features /> nonza.
|
||||||
|
///
|
||||||
|
/// Returns the next state of the negotiator. If done or retryLater is selected, then
|
||||||
|
/// negotiator won't be called again. If retryLater is returned, then the negotiator
|
||||||
|
/// must switch some internal state to prevent getting matched immediately again.
|
||||||
|
/// If ready is returned, then the negotiator indicates that it is not done with
|
||||||
|
/// negotiation.
|
||||||
|
Future<void> negotiate(XMLNode nonza);
|
||||||
|
|
||||||
|
/// Reset the negotiator to a state that negotation can happen again.
|
||||||
|
void reset() {
|
||||||
|
state = NegotiatorState.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
NegotiatorAttributes get attributes => _attributes;
|
||||||
|
}
|
66
moxxmpp/lib/src/negotiators/resource_binding.dart
Normal file
66
moxxmpp/lib/src/negotiators/resource_binding.dart
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/negotiator.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0198/xep_0198.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
class ResourceBindingNegotiator extends XmppFeatureNegotiatorBase {
|
||||||
|
|
||||||
|
ResourceBindingNegotiator() : _requestSent = false, super(0, false, bindXmlns, resourceBindingNegotiator);
|
||||||
|
bool _requestSent;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool matchesFeature(List<XMLNode> features) {
|
||||||
|
final sm = attributes.getManagerById<StreamManagementManager>(smManager);
|
||||||
|
if (sm != null) {
|
||||||
|
return super.matchesFeature(features) && !sm.streamResumed && attributes.isAuthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.matchesFeature(features) && attributes.isAuthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> negotiate(XMLNode nonza) async {
|
||||||
|
if (!_requestSent) {
|
||||||
|
final stanza = XMLNode.xmlns(
|
||||||
|
tag: 'iq',
|
||||||
|
xmlns: stanzaXmlns,
|
||||||
|
attributes: {
|
||||||
|
'type': 'set',
|
||||||
|
'id': const Uuid().v4(),
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'bind',
|
||||||
|
xmlns: bindXmlns,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
_requestSent = true;
|
||||||
|
attributes.sendNonza(stanza);
|
||||||
|
} else {
|
||||||
|
if (nonza.tag != 'iq' || nonza.attributes['type'] != 'result') {
|
||||||
|
state = NegotiatorState.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bind = nonza.firstTag('bind')!;
|
||||||
|
final jid = bind.firstTag('jid')!;
|
||||||
|
final resource = jid.innerText().split('/')[1];
|
||||||
|
|
||||||
|
await attributes.sendEvent(ResourceBindingSuccessEvent(resource: resource));
|
||||||
|
state = NegotiatorState.done;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void reset() {
|
||||||
|
_requestSent = false;
|
||||||
|
|
||||||
|
super.reset();
|
||||||
|
}
|
||||||
|
}
|
46
moxxmpp/lib/src/negotiators/sasl/kv.dart
Normal file
46
moxxmpp/lib/src/negotiators/sasl/kv.dart
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
enum ParserState {
|
||||||
|
variableName,
|
||||||
|
variableValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a string like "n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL" into
|
||||||
|
/// { "n": "user", "r": "fyko+d2lbbFgONRv9qkxdawL"}.
|
||||||
|
Map<String, String> parseKeyValue(String keyValueString) {
|
||||||
|
var state = ParserState.variableName;
|
||||||
|
var name = '';
|
||||||
|
var value = '';
|
||||||
|
final values = <String, String>{};
|
||||||
|
|
||||||
|
for (var i = 0; i < keyValueString.length; i++) {
|
||||||
|
final char = keyValueString[i];
|
||||||
|
switch (state) {
|
||||||
|
case ParserState.variableName: {
|
||||||
|
if (char == '=') {
|
||||||
|
state = ParserState.variableValue;
|
||||||
|
} else if (char == ',') {
|
||||||
|
name = '';
|
||||||
|
} else {
|
||||||
|
name += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ParserState.variableValue: {
|
||||||
|
if (char == ',' || i == keyValueString.length - 1) {
|
||||||
|
if (char != ',') {
|
||||||
|
value += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
values[name] = value;
|
||||||
|
value = '';
|
||||||
|
name = '';
|
||||||
|
state = ParserState.variableName;
|
||||||
|
} else {
|
||||||
|
value += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
27
moxxmpp/lib/src/negotiators/sasl/negotiator.dart
Normal file
27
moxxmpp/lib/src/negotiators/sasl/negotiator.dart
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/negotiator.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
abstract class SaslNegotiator extends XmppFeatureNegotiatorBase {
|
||||||
|
|
||||||
|
SaslNegotiator(int priority, String id, this.mechanismName) : super(priority, true, saslXmlns, id);
|
||||||
|
/// The name inside the <mechanism /> element
|
||||||
|
final String mechanismName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool matchesFeature(List<XMLNode> features) {
|
||||||
|
// Is SASL advertised?
|
||||||
|
final mechanisms = firstWhereOrNull(
|
||||||
|
features,
|
||||||
|
(XMLNode feature) => feature.attributes['xmlns'] == saslXmlns,
|
||||||
|
);
|
||||||
|
if (mechanisms == null) return false;
|
||||||
|
|
||||||
|
// Is SASL PLAIN advertised?
|
||||||
|
return firstWhereOrNull(
|
||||||
|
mechanisms.children,
|
||||||
|
(XMLNode mechanism) => mechanism.text == mechanismName,
|
||||||
|
) != null;
|
||||||
|
}
|
||||||
|
}
|
13
moxxmpp/lib/src/negotiators/sasl/nonza.dart
Normal file
13
moxxmpp/lib/src/negotiators/sasl/nonza.dart
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
class SaslAuthNonza extends XMLNode {
|
||||||
|
SaslAuthNonza(String mechanism, String body) : super(
|
||||||
|
tag: 'auth',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'xmlns': saslXmlns,
|
||||||
|
'mechanism': mechanism ,
|
||||||
|
},
|
||||||
|
text: body,
|
||||||
|
);
|
||||||
|
}
|
73
moxxmpp/lib/src/negotiators/sasl/plain.dart
Normal file
73
moxxmpp/lib/src/negotiators/sasl/plain.dart
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/negotiator.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/sasl/negotiator.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/sasl/nonza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
class SaslPlainAuthNonza extends SaslAuthNonza {
|
||||||
|
SaslPlainAuthNonza(String username, String password) : super(
|
||||||
|
'PLAIN', base64.encode(utf8.encode('\u0000$username\u0000$password')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SaslPlainNegotiator extends SaslNegotiator {
|
||||||
|
|
||||||
|
SaslPlainNegotiator()
|
||||||
|
: _authSent = false,
|
||||||
|
_log = Logger('SaslPlainNegotiator'),
|
||||||
|
super(0, saslPlainNegotiator, 'PLAIN');
|
||||||
|
bool _authSent;
|
||||||
|
|
||||||
|
final Logger _log;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool matchesFeature(List<XMLNode> features) {
|
||||||
|
if (!attributes.getConnectionSettings().allowPlainAuth) return false;
|
||||||
|
|
||||||
|
if (super.matchesFeature(features)) {
|
||||||
|
if (!attributes.getSocket().isSecure()) {
|
||||||
|
_log.warning('Refusing to match SASL feature due to unsecured connection');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> negotiate(XMLNode nonza) async {
|
||||||
|
if (!_authSent) {
|
||||||
|
final settings = attributes.getConnectionSettings();
|
||||||
|
attributes.sendNonza(
|
||||||
|
SaslPlainAuthNonza(settings.jid.local, settings.password),
|
||||||
|
redact: SaslPlainAuthNonza('******', '******').toXml(),
|
||||||
|
);
|
||||||
|
_authSent = true;
|
||||||
|
} else {
|
||||||
|
final tag = nonza.tag;
|
||||||
|
if (tag == 'success') {
|
||||||
|
await attributes.sendEvent(AuthenticationSuccessEvent());
|
||||||
|
state = NegotiatorState.done;
|
||||||
|
} else {
|
||||||
|
// We assume it's a <failure/>
|
||||||
|
final error = nonza.children.first.tag;
|
||||||
|
await attributes.sendEvent(AuthenticationFailedEvent(error));
|
||||||
|
|
||||||
|
state = NegotiatorState.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void reset() {
|
||||||
|
_authSent = false;
|
||||||
|
|
||||||
|
super.reset();
|
||||||
|
}
|
||||||
|
}
|
259
moxxmpp/lib/src/negotiators/sasl/scram.dart
Normal file
259
moxxmpp/lib/src/negotiators/sasl/scram.dart
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math' show Random;
|
||||||
|
|
||||||
|
import 'package:cryptography/cryptography.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/negotiator.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/sasl/kv.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/sasl/negotiator.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/sasl/nonza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:random_string/random_string.dart';
|
||||||
|
import 'package:saslprep/saslprep.dart';
|
||||||
|
|
||||||
|
// NOTE: Inspired by https://github.com/vukoye/xmpp_dart/blob/3b1a0588562b9e591488c99d834088391840911d/lib/src/features/sasl/ScramSaslHandler.dart
|
||||||
|
|
||||||
|
enum ScramHashType {
|
||||||
|
sha1,
|
||||||
|
sha256,
|
||||||
|
sha512
|
||||||
|
}
|
||||||
|
|
||||||
|
HashAlgorithm hashFromType(ScramHashType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ScramHashType.sha1: return Sha1();
|
||||||
|
case ScramHashType.sha256: return Sha256();
|
||||||
|
case ScramHashType.sha512: return Sha512();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scramSha1Mechanism = 'SCRAM-SHA-1';
|
||||||
|
const scramSha256Mechanism = 'SCRAM-SHA-256';
|
||||||
|
const scramSha512Mechanism = 'SCRAM-SHA-512';
|
||||||
|
|
||||||
|
String mechanismNameFromType(ScramHashType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ScramHashType.sha1: return scramSha1Mechanism;
|
||||||
|
case ScramHashType.sha256: return scramSha256Mechanism;
|
||||||
|
case ScramHashType.sha512: return scramSha512Mechanism;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String namespaceFromType(ScramHashType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ScramHashType.sha1: return saslScramSha1Negotiator;
|
||||||
|
case ScramHashType.sha256: return saslScramSha256Negotiator;
|
||||||
|
case ScramHashType.sha512: return saslScramSha512Negotiator;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SaslScramAuthNonza extends SaslAuthNonza {
|
||||||
|
// This subclassing makes less sense here, but this is since the auth nonza here
|
||||||
|
// requires knowledge of the inner state of the Negotiator.
|
||||||
|
SaslScramAuthNonza({ required ScramHashType type, required String body }) : super(
|
||||||
|
mechanismNameFromType(type), body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SaslScramResponseNonza extends XMLNode {
|
||||||
|
SaslScramResponseNonza({ required String body }) : super(
|
||||||
|
tag: 'response',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'xmlns': saslXmlns,
|
||||||
|
},
|
||||||
|
text: body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ScramState {
|
||||||
|
preSent,
|
||||||
|
initialMessageSent,
|
||||||
|
challengeResponseSent,
|
||||||
|
error
|
||||||
|
}
|
||||||
|
|
||||||
|
const gs2Header = 'n,,';
|
||||||
|
|
||||||
|
class SaslScramNegotiator extends SaslNegotiator {
|
||||||
|
|
||||||
|
// NOTE: NEVER, and I mean, NEVER set clientNonce or initalMessageNoGS2. They are just there for testing
|
||||||
|
SaslScramNegotiator(
|
||||||
|
int priority,
|
||||||
|
this.initialMessageNoGS2,
|
||||||
|
this.clientNonce,
|
||||||
|
this.hashType,
|
||||||
|
) :
|
||||||
|
_hash = hashFromType(hashType),
|
||||||
|
_serverSignature = '',
|
||||||
|
_scramState = ScramState.preSent,
|
||||||
|
_log = Logger('SaslScramNegotiator(${mechanismNameFromType(hashType)})'),
|
||||||
|
super(priority, namespaceFromType(hashType), mechanismNameFromType(hashType));
|
||||||
|
String? clientNonce;
|
||||||
|
String initialMessageNoGS2;
|
||||||
|
final ScramHashType hashType;
|
||||||
|
final HashAlgorithm _hash;
|
||||||
|
String _serverSignature;
|
||||||
|
|
||||||
|
// The internal state for performing the negotiation
|
||||||
|
ScramState _scramState;
|
||||||
|
|
||||||
|
final Logger _log;
|
||||||
|
|
||||||
|
Future<List<int>> calculateSaltedPassword(String salt, int iterations) async {
|
||||||
|
final pbkdf2 = Pbkdf2(
|
||||||
|
macAlgorithm: Hmac(_hash),
|
||||||
|
iterations: iterations,
|
||||||
|
bits: 160, // NOTE: RFC says 20 octets => 20 octets * 8 bits/octet
|
||||||
|
);
|
||||||
|
|
||||||
|
final saltedPasswordRaw = await pbkdf2.deriveKey(
|
||||||
|
secretKey: SecretKey(
|
||||||
|
utf8.encode(Saslprep.saslprep(attributes.getConnectionSettings().password)),
|
||||||
|
),
|
||||||
|
nonce: base64.decode(salt),
|
||||||
|
);
|
||||||
|
return saltedPasswordRaw.extractBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>> calculateClientKey(List<int> saltedPassword) async {
|
||||||
|
return (await Hmac(_hash).calculateMac(
|
||||||
|
utf8.encode('Client Key'), secretKey: SecretKey(saltedPassword),
|
||||||
|
)).bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>> calculateClientSignature(String authMessage, List<int> storedKey) async {
|
||||||
|
return (await Hmac(_hash).calculateMac(
|
||||||
|
utf8.encode(authMessage),
|
||||||
|
secretKey: SecretKey(storedKey),
|
||||||
|
)).bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>> calculateServerKey(List<int> saltedPassword) async {
|
||||||
|
return (await Hmac(_hash).calculateMac(
|
||||||
|
utf8.encode('Server Key'),
|
||||||
|
secretKey: SecretKey(saltedPassword),
|
||||||
|
)).bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>> calculateServerSignature(String authMessage, List<int> serverKey) async {
|
||||||
|
return (await Hmac(_hash).calculateMac(
|
||||||
|
utf8.encode(authMessage),
|
||||||
|
secretKey: SecretKey(serverKey),
|
||||||
|
)).bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> calculateClientProof(List<int> clientKey, List<int> clientSignature) {
|
||||||
|
final clientProof = List<int>.filled(clientKey.length, 0);
|
||||||
|
for (var i = 0; i < clientKey.length; i++) {
|
||||||
|
clientProof[i] = clientKey[i] ^ clientSignature[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return clientProof;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> calculateChallengeResponse(String base64Challenge) async {
|
||||||
|
final challengeString = utf8.decode(base64.decode(base64Challenge));
|
||||||
|
final challenge = parseKeyValue(challengeString);
|
||||||
|
final clientFinalMessageBare = 'c=biws,r=${challenge['r']!}';
|
||||||
|
|
||||||
|
final saltedPassword = await calculateSaltedPassword(challenge['s']!, int.parse(challenge['i']!));
|
||||||
|
final clientKey = await calculateClientKey(saltedPassword);
|
||||||
|
final storedKey = (await _hash.hash(clientKey)).bytes;
|
||||||
|
final authMessage = '$initialMessageNoGS2,$challengeString,$clientFinalMessageBare';
|
||||||
|
final clientSignature = await calculateClientSignature(authMessage, storedKey);
|
||||||
|
final clientProof = calculateClientProof(clientKey, clientSignature);
|
||||||
|
final serverKey = await calculateServerKey(saltedPassword);
|
||||||
|
_serverSignature = base64.encode(await calculateServerSignature(authMessage, serverKey));
|
||||||
|
|
||||||
|
return '$clientFinalMessageBare,p=${base64.encode(clientProof)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool matchesFeature(List<XMLNode> features) {
|
||||||
|
if (super.matchesFeature(features)) {
|
||||||
|
if (!attributes.getSocket().isSecure()) {
|
||||||
|
_log.warning('Refusing to match SASL feature due to unsecured connection');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> negotiate(XMLNode nonza) async {
|
||||||
|
switch (_scramState) {
|
||||||
|
case ScramState.preSent:
|
||||||
|
if (clientNonce == null || clientNonce == '') {
|
||||||
|
clientNonce = randomAlphaNumeric(40, provider: CoreRandomProvider.from(Random.secure()));
|
||||||
|
}
|
||||||
|
|
||||||
|
initialMessageNoGS2 = 'n=${attributes.getConnectionSettings().jid.local},r=$clientNonce';
|
||||||
|
|
||||||
|
_scramState = ScramState.initialMessageSent;
|
||||||
|
attributes.sendNonza(
|
||||||
|
SaslScramAuthNonza(body: base64.encode(utf8.encode(gs2Header + initialMessageNoGS2)), type: hashType),
|
||||||
|
redact: SaslScramAuthNonza(body: '******', type: hashType).toXml(),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case ScramState.initialMessageSent:
|
||||||
|
if (nonza.tag != 'challenge') {
|
||||||
|
final error = nonza.children.first.tag;
|
||||||
|
await attributes.sendEvent(AuthenticationFailedEvent(error));
|
||||||
|
|
||||||
|
state = NegotiatorState.error;
|
||||||
|
_scramState = ScramState.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final challengeBase64 = nonza.innerText();
|
||||||
|
final response = await calculateChallengeResponse(challengeBase64);
|
||||||
|
final responseBase64 = base64.encode(utf8.encode(response));
|
||||||
|
_scramState = ScramState.challengeResponseSent;
|
||||||
|
attributes.sendNonza(
|
||||||
|
SaslScramResponseNonza(body: responseBase64),
|
||||||
|
redact: SaslScramResponseNonza(body: '******').toXml(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case ScramState.challengeResponseSent:
|
||||||
|
if (nonza.tag != 'success') {
|
||||||
|
// We assume it's a <failure />
|
||||||
|
final error = nonza.children.first.tag;
|
||||||
|
await attributes.sendEvent(AuthenticationFailedEvent(error));
|
||||||
|
_scramState = ScramState.error;
|
||||||
|
state = NegotiatorState.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: This assumes that the string is always "v=..." and contains no other parameters
|
||||||
|
final signature = parseKeyValue(utf8.decode(base64.decode(nonza.innerText())));
|
||||||
|
if (signature['v']! != _serverSignature) {
|
||||||
|
// TODO(Unknown): Notify of a signature mismatch
|
||||||
|
//final error = nonza.children.first.tag;
|
||||||
|
//attributes.sendEvent(AuthenticationFailedEvent(error));
|
||||||
|
_scramState = ScramState.error;
|
||||||
|
state = NegotiatorState.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await attributes.sendEvent(AuthenticationSuccessEvent());
|
||||||
|
state = NegotiatorState.done;
|
||||||
|
return;
|
||||||
|
case ScramState.error:
|
||||||
|
state = NegotiatorState.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void reset() {
|
||||||
|
_scramState = ScramState.preSent;
|
||||||
|
|
||||||
|
super.reset();
|
||||||
|
}
|
||||||
|
}
|
65
moxxmpp/lib/src/negotiators/starttls.dart
Normal file
65
moxxmpp/lib/src/negotiators/starttls.dart
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/negotiator.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
enum _StartTlsState {
|
||||||
|
ready,
|
||||||
|
requested
|
||||||
|
}
|
||||||
|
|
||||||
|
class StartTLSNonza extends XMLNode {
|
||||||
|
StartTLSNonza() : super.xmlns(
|
||||||
|
tag: 'starttls',
|
||||||
|
xmlns: startTlsXmlns,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class StartTlsNegotiator extends XmppFeatureNegotiatorBase {
|
||||||
|
|
||||||
|
StartTlsNegotiator()
|
||||||
|
: _state = _StartTlsState.ready,
|
||||||
|
_log = Logger('StartTlsNegotiator'),
|
||||||
|
super(10, true, startTlsXmlns, startTlsNegotiator);
|
||||||
|
_StartTlsState _state;
|
||||||
|
|
||||||
|
final Logger _log;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> negotiate(XMLNode nonza) async {
|
||||||
|
switch (_state) {
|
||||||
|
case _StartTlsState.ready:
|
||||||
|
_log.fine('StartTLS is available. Performing StartTLS upgrade...');
|
||||||
|
_state = _StartTlsState.requested;
|
||||||
|
attributes.sendNonza(StartTLSNonza());
|
||||||
|
break;
|
||||||
|
case _StartTlsState.requested:
|
||||||
|
if (nonza.tag != 'proceed' || nonza.attributes['xmlns'] != startTlsXmlns) {
|
||||||
|
_log.severe('Failed to perform StartTLS negotiation');
|
||||||
|
state = NegotiatorState.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.fine('Securing socket');
|
||||||
|
final result = await attributes.getSocket()
|
||||||
|
.secure(attributes.getConnectionSettings().jid.domain);
|
||||||
|
if (!result) {
|
||||||
|
_log.severe('Failed to secure stream');
|
||||||
|
state = NegotiatorState.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.fine('Stream is now TLS secured');
|
||||||
|
state = NegotiatorState.done;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void reset() {
|
||||||
|
_state = _StartTlsState.ready;
|
||||||
|
|
||||||
|
super.reset();
|
||||||
|
}
|
||||||
|
}
|
52
moxxmpp/lib/src/ping.dart
Normal file
52
moxxmpp/lib/src/ping.dart
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0198/xep_0198.dart';
|
||||||
|
|
||||||
|
class PingManager extends XmppManagerBase {
|
||||||
|
@override
|
||||||
|
String getId() => pingManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'PingManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
void _logWarning() {
|
||||||
|
logger.warning('Cannot send keepalives as SM is not available, the socket disallows whitespace pings and does not manage its own keepalives. Cannot guarantee that the connection survives.');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onXmppEvent(XmppEvent event) async {
|
||||||
|
if (event is SendPingEvent) {
|
||||||
|
logger.finest('Received ping event.');
|
||||||
|
final attrs = getAttributes();
|
||||||
|
final socket = attrs.getSocket();
|
||||||
|
|
||||||
|
if (socket.managesKeepalives()) {
|
||||||
|
logger.finest('Not sending ping as the socket manages it.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final stream = attrs.getManagerById(smManager) as StreamManagementManager?;
|
||||||
|
if (stream != null) {
|
||||||
|
if (stream.isStreamManagementEnabled() /*&& stream.getUnackedStanzaCount() > 0*/) {
|
||||||
|
logger.finest('Sending an ack ping as Stream Management is enabled');
|
||||||
|
stream.sendAckRequestPing();
|
||||||
|
} else if (attrs.getSocket().whitespacePingAllowed()) {
|
||||||
|
logger.finest('Sending a whitespace ping as Stream Management is not enabled');
|
||||||
|
attrs.getConnection().sendWhitespacePing();
|
||||||
|
} else {
|
||||||
|
_logWarning();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (attrs.getSocket().whitespacePingAllowed()) {
|
||||||
|
attrs.getConnection().sendWhitespacePing();
|
||||||
|
} else {
|
||||||
|
_logWarning();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
158
moxxmpp/lib/src/presence.dart
Normal file
158
moxxmpp/lib/src/presence.dart
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import 'package:moxxmpp/src/connection.dart';
|
||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/types.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0115.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0414.dart';
|
||||||
|
|
||||||
|
class PresenceManager extends XmppManagerBase {
|
||||||
|
|
||||||
|
PresenceManager() : _capabilityHash = null, super();
|
||||||
|
String? _capabilityHash;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => presenceManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'PresenceManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'presence',
|
||||||
|
callback: _onPresence,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> getDiscoFeatures() => [ capsXmlns ];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onPresence(Stanza presence, StanzaHandlerData state) async {
|
||||||
|
final attrs = getAttributes();
|
||||||
|
switch (presence.type) {
|
||||||
|
case 'subscribe':
|
||||||
|
case 'subscribed': {
|
||||||
|
attrs.sendEvent(
|
||||||
|
SubscriptionRequestReceivedEvent(from: JID.fromString(presence.from!)),
|
||||||
|
);
|
||||||
|
return state.copyWith(done: true);
|
||||||
|
}
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (presence.from != null) {
|
||||||
|
logger.finest("Received presence from '${presence.from}'");
|
||||||
|
|
||||||
|
getAttributes().sendEvent(PresenceReceivedEvent(JID.fromString(presence.from!), presence));
|
||||||
|
return state.copyWith(done: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the capability hash.
|
||||||
|
Future<String> getCapabilityHash() async {
|
||||||
|
final manager = getAttributes().getManagerById(discoManager)! as DiscoManager;
|
||||||
|
_capabilityHash ??= await calculateCapabilityHash(
|
||||||
|
DiscoInfo(
|
||||||
|
manager.getRegisteredDiscoFeatures(),
|
||||||
|
manager.getIdentities(),
|
||||||
|
[],
|
||||||
|
getAttributes().getFullJID(),
|
||||||
|
),
|
||||||
|
getHashByName('sha-1')!,
|
||||||
|
);
|
||||||
|
|
||||||
|
return _capabilityHash!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the initial presence to enable receiving messages.
|
||||||
|
Future<void> sendInitialPresence() async {
|
||||||
|
final attrs = getAttributes();
|
||||||
|
attrs.sendNonza(
|
||||||
|
Stanza.presence(
|
||||||
|
from: attrs.getFullJID().toString(),
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'show',
|
||||||
|
text: 'chat',
|
||||||
|
),
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'c',
|
||||||
|
xmlns: capsXmlns,
|
||||||
|
attributes: {
|
||||||
|
'hash': 'sha-1',
|
||||||
|
'node': 'http://moxxy.im',
|
||||||
|
'ver': await getCapabilityHash()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send an unavailable presence with no 'to' attribute.
|
||||||
|
void sendUnavailablePresence() {
|
||||||
|
getAttributes().sendStanza(
|
||||||
|
Stanza.presence(
|
||||||
|
type: 'unavailable',
|
||||||
|
),
|
||||||
|
addFrom: StanzaFromType.full,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a subscription request to [to].
|
||||||
|
void sendSubscriptionRequest(String to) {
|
||||||
|
getAttributes().sendStanza(
|
||||||
|
Stanza.presence(
|
||||||
|
type: 'subscribe',
|
||||||
|
to: to,
|
||||||
|
),
|
||||||
|
addFrom: StanzaFromType.none,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends an unsubscription request to [to].
|
||||||
|
void sendUnsubscriptionRequest(String to) {
|
||||||
|
getAttributes().sendStanza(
|
||||||
|
Stanza.presence(
|
||||||
|
type: 'unsubscribe',
|
||||||
|
to: to,
|
||||||
|
),
|
||||||
|
addFrom: StanzaFromType.none,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept a presence subscription request for [to].
|
||||||
|
void sendSubscriptionRequestApproval(String to) {
|
||||||
|
getAttributes().sendStanza(
|
||||||
|
Stanza.presence(
|
||||||
|
type: 'subscribed',
|
||||||
|
to: to,
|
||||||
|
),
|
||||||
|
addFrom: StanzaFromType.none,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject a presence subscription request for [to].
|
||||||
|
void sendSubscriptionRequestRejection(String to) {
|
||||||
|
getAttributes().sendStanza(
|
||||||
|
Stanza.presence(
|
||||||
|
type: 'unsubscribed',
|
||||||
|
to: to,
|
||||||
|
),
|
||||||
|
addFrom: StanzaFromType.none,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
150
moxxmpp/lib/src/reconnect.dart
Normal file
150
moxxmpp/lib/src/reconnect.dart
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:meta/meta.dart';
|
||||||
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
|
abstract class ReconnectionPolicy {
|
||||||
|
|
||||||
|
ReconnectionPolicy()
|
||||||
|
: _shouldAttemptReconnection = false,
|
||||||
|
_isReconnecting = false,
|
||||||
|
_isReconnectingLock = Lock();
|
||||||
|
/// Function provided by XmppConnection that allows the policy
|
||||||
|
/// to perform a reconnection.
|
||||||
|
Future<void> Function()? performReconnect;
|
||||||
|
/// Function provided by XmppConnection that allows the policy
|
||||||
|
/// to say that we lost the connection.
|
||||||
|
void Function()? triggerConnectionLost;
|
||||||
|
/// Indicate if should try to reconnect.
|
||||||
|
bool _shouldAttemptReconnection;
|
||||||
|
/// Indicate if a reconnection attempt is currently running.
|
||||||
|
bool _isReconnecting;
|
||||||
|
/// And the corresponding lock
|
||||||
|
final Lock _isReconnectingLock;
|
||||||
|
|
||||||
|
/// Called by XmppConnection to register the policy.
|
||||||
|
void register(Future<void> Function() performReconnect, void Function() triggerConnectionLost) {
|
||||||
|
this.performReconnect = performReconnect;
|
||||||
|
this.triggerConnectionLost = triggerConnectionLost;
|
||||||
|
|
||||||
|
unawaited(reset());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In case the policy depends on some internal state, this state must be reset
|
||||||
|
/// to an initial state when reset is called. In case timers run, they must be
|
||||||
|
/// terminated.
|
||||||
|
Future<void> reset();
|
||||||
|
|
||||||
|
/// Called by the XmppConnection when the reconnection failed.
|
||||||
|
Future<void> onFailure() async {}
|
||||||
|
|
||||||
|
/// Caled by the XmppConnection when the reconnection was successful.
|
||||||
|
Future<void> onSuccess();
|
||||||
|
|
||||||
|
bool get shouldReconnect => _shouldAttemptReconnection;
|
||||||
|
|
||||||
|
/// Set whether a reconnection attempt should be made.
|
||||||
|
void setShouldReconnect(bool value) {
|
||||||
|
_shouldAttemptReconnection = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the manager is currently triggering a reconnection. If not, returns
|
||||||
|
/// false.
|
||||||
|
Future<bool> isReconnectionRunning() async {
|
||||||
|
return _isReconnectingLock.synchronized(() => _isReconnecting);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the _isReconnecting state to [value].
|
||||||
|
@protected
|
||||||
|
Future<void> setIsReconnecting(bool value) async {
|
||||||
|
await _isReconnectingLock.synchronized(() async {
|
||||||
|
_isReconnecting = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
Future<bool> testAndSetIsReconnecting() async {
|
||||||
|
return _isReconnectingLock.synchronized(() {
|
||||||
|
if (_isReconnecting) {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
_isReconnecting = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A simple reconnection strategy: Make the reconnection delays exponentially longer
|
||||||
|
/// for every failed attempt.
|
||||||
|
class ExponentialBackoffReconnectionPolicy extends ReconnectionPolicy {
|
||||||
|
|
||||||
|
ExponentialBackoffReconnectionPolicy()
|
||||||
|
: _counter = 0,
|
||||||
|
_log = Logger('ExponentialBackoffReconnectionPolicy'),
|
||||||
|
super();
|
||||||
|
int _counter;
|
||||||
|
Timer? _timer;
|
||||||
|
final Logger _log;
|
||||||
|
|
||||||
|
/// Called when the backoff expired
|
||||||
|
Future<void> _onTimerElapsed() async {
|
||||||
|
final isReconnecting = await isReconnectionRunning();
|
||||||
|
if (shouldReconnect) {
|
||||||
|
if (!isReconnecting) {
|
||||||
|
await performReconnect!();
|
||||||
|
} else {
|
||||||
|
// Should never happen.
|
||||||
|
_log.fine('Backoff timer expired but reconnection is running, so doing nothing.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reset() async {
|
||||||
|
_log.finest('Resetting internal state');
|
||||||
|
_counter = 0;
|
||||||
|
await setIsReconnecting(false);
|
||||||
|
|
||||||
|
if (_timer != null) {
|
||||||
|
_timer!.cancel();
|
||||||
|
_timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onFailure() async {
|
||||||
|
_log.finest('Failure occured. Starting exponential backoff');
|
||||||
|
_counter++;
|
||||||
|
await setIsReconnecting(true);
|
||||||
|
|
||||||
|
if (_timer != null) {
|
||||||
|
_timer!.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait at max 80 seconds.
|
||||||
|
final seconds = min(pow(2, _counter).toInt(), 80);
|
||||||
|
_timer = Timer(Duration(seconds: seconds), _onTimerElapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onSuccess() async {
|
||||||
|
await reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stub reconnection policy for tests
|
||||||
|
@visibleForTesting
|
||||||
|
class TestingReconnectionPolicy extends ReconnectionPolicy {
|
||||||
|
TestingReconnectionPolicy() : super();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onSuccess() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onFailure() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reset() async {}
|
||||||
|
}
|
22
moxxmpp/lib/src/rfcs/rfc_2782.dart
Normal file
22
moxxmpp/lib/src/rfcs/rfc_2782.dart
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/*import 'package:moxdns/moxdns.dart';
|
||||||
|
|
||||||
|
/// Sorts the SRV records according to priority and weight.
|
||||||
|
int srvRecordSortComparator(SrvRecord a, SrvRecord b) {
|
||||||
|
if (a.priority < b.priority) {
|
||||||
|
return -1;
|
||||||
|
} else {
|
||||||
|
if (a.priority > b.priority) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a.priority == b.priority
|
||||||
|
if (a.weight < b.weight) {
|
||||||
|
return -1;
|
||||||
|
} else if (a.weight > b.weight) {
|
||||||
|
return 1;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
26
moxxmpp/lib/src/rfcs/rfc_4790.dart
Normal file
26
moxxmpp/lib/src/rfcs/rfc_4790.dart
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
/// A sort comparator using the i;octet collation defined by RFC 4790
|
||||||
|
// TODO(Unknown): Maybe enforce utf8?
|
||||||
|
int ioctetSortComparator(String a, String b) {
|
||||||
|
if (a.isEmpty && b.isEmpty) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.isEmpty && b.isNotEmpty) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.isNotEmpty && b.isEmpty) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a[0] == b[0]) {
|
||||||
|
return ioctetSortComparator(a.substring(1), b.substring(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(Unknown): Is this correct?
|
||||||
|
if (a.codeUnitAt(0) < b.codeUnitAt(0)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
322
moxxmpp/lib/src/roster.dart
Normal file
322
moxxmpp/lib/src/roster.dart
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/negotiator.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/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
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
}
|
6
moxxmpp/lib/src/routing.dart
Normal file
6
moxxmpp/lib/src/routing.dart
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
enum RoutingState {
|
||||||
|
error,
|
||||||
|
preConnection,
|
||||||
|
negotiating,
|
||||||
|
handleStanzas
|
||||||
|
}
|
10
moxxmpp/lib/src/settings.dart
Normal file
10
moxxmpp/lib/src/settings.dart
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
|
||||||
|
class ConnectionSettings {
|
||||||
|
|
||||||
|
ConnectionSettings({ required this.jid, required this.password, required this.useDirectTLS, required this.allowPlainAuth });
|
||||||
|
final JID jid;
|
||||||
|
final String password;
|
||||||
|
final bool useDirectTLS;
|
||||||
|
final bool allowPlainAuth;
|
||||||
|
}
|
343
moxxmpp/lib/src/socket.dart
Normal file
343
moxxmpp/lib/src/socket.dart
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
//import 'package:moxdns/moxdns.dart';
|
||||||
|
//import 'package:moxxmpp/src/rfcs/rfc_2782.dart';
|
||||||
|
|
||||||
|
// NOTE: https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids
|
||||||
|
const xmppClientALPNId = 'xmpp-client';
|
||||||
|
|
||||||
|
abstract class XmppSocketEvent {}
|
||||||
|
|
||||||
|
/// Triggered by the socket when an error occurs.
|
||||||
|
class XmppSocketErrorEvent extends XmppSocketEvent {
|
||||||
|
|
||||||
|
XmppSocketErrorEvent(this.error);
|
||||||
|
final Object error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when the socket is closed
|
||||||
|
class XmppSocketClosureEvent extends XmppSocketEvent {}
|
||||||
|
|
||||||
|
/// This class is the base for a socket that XmppConnection can use.
|
||||||
|
abstract class BaseSocketWrapper {
|
||||||
|
/// This must return the unbuffered string stream that the socket receives.
|
||||||
|
Stream<String> getDataStream();
|
||||||
|
|
||||||
|
/// This must return events generated by the socket.
|
||||||
|
/// See sub-classes of [XmppSocketEvent] for possible events.
|
||||||
|
Stream<XmppSocketEvent> getEventStream();
|
||||||
|
|
||||||
|
/// This must close the socket but not the streams so that the same class can be
|
||||||
|
/// reused by calling [this.connect] again.
|
||||||
|
void close();
|
||||||
|
|
||||||
|
/// Write [data] into the socket. If [redact] is not null, then [redact] will be
|
||||||
|
/// logged instead of [data].
|
||||||
|
void write(String data, { String? redact });
|
||||||
|
|
||||||
|
/// This must connect to [host]:[port] and initialize the streams accordingly.
|
||||||
|
/// [domain] is the domain that TLS should be validated against, in case the Socket
|
||||||
|
/// provides TLS encryption. Returns true if the connection has been successfully
|
||||||
|
/// established. Returns false if the connection has failed.
|
||||||
|
Future<bool> connect(String domain, { String? host, int? port });
|
||||||
|
|
||||||
|
/// Returns true if the socket is secured, e.g. using TLS.
|
||||||
|
bool isSecure();
|
||||||
|
|
||||||
|
/// Upgrades the connection into a secure version, e.g. by performing a TLS upgrade.
|
||||||
|
/// May do nothing if the connection is always secure.
|
||||||
|
/// Returns true if the socket has been successfully upgraded. False otherwise.
|
||||||
|
Future<bool> secure(String domain);
|
||||||
|
|
||||||
|
/// Returns true if whitespace pings are allowed. False if not.
|
||||||
|
bool whitespacePingAllowed();
|
||||||
|
|
||||||
|
/// Returns true if it manages its own keepalive pings, like websockets. False if not.
|
||||||
|
bool managesKeepalives();
|
||||||
|
|
||||||
|
/// Brings the socket into a state that allows it to close without triggering any errors
|
||||||
|
/// to the XmppConnection.
|
||||||
|
void prepareDisconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TCP socket implementation for XmppConnection
|
||||||
|
/*class TCPSocketWrapper extends BaseSocketWrapper {
|
||||||
|
|
||||||
|
TCPSocketWrapper(this._logData)
|
||||||
|
: _log = Logger('TCPSocketWrapper'),
|
||||||
|
_dataStream = StreamController.broadcast(),
|
||||||
|
_eventStream = StreamController.broadcast(),
|
||||||
|
_secure = false,
|
||||||
|
_ignoreSocketClosure = false;
|
||||||
|
Socket? _socket;
|
||||||
|
bool _ignoreSocketClosure;
|
||||||
|
final StreamController<String> _dataStream;
|
||||||
|
final StreamController<XmppSocketEvent> _eventStream;
|
||||||
|
StreamSubscription<dynamic>? _socketSubscription;
|
||||||
|
|
||||||
|
final Logger _log;
|
||||||
|
final bool _logData;
|
||||||
|
|
||||||
|
bool _secure;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool isSecure() => _secure;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool whitespacePingAllowed() => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool managesKeepalives() => false;
|
||||||
|
|
||||||
|
/// Allow the socket to be destroyed by cancelling internal subscriptions.
|
||||||
|
void destroy() {
|
||||||
|
_socketSubscription?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _onBadCertificate(dynamic certificate, String domain) {
|
||||||
|
_log.fine('Bad certificate: ${certificate.toString()}');
|
||||||
|
//final isExpired = certificate.endValidity.isAfter(DateTime.now());
|
||||||
|
// TODO(Unknown): Either validate the certificate ourselves or use a platform native
|
||||||
|
// hostname verifier (or Dart adds it themselves)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _xep368Connect(String domain) async {
|
||||||
|
// TODO(Unknown): Maybe do DNSSEC one day
|
||||||
|
final results = await MoxdnsPlugin.srvQuery('_xmpps-client._tcp.$domain', false);
|
||||||
|
if (results.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort(srvRecordSortComparator);
|
||||||
|
for (final srv in results) {
|
||||||
|
try {
|
||||||
|
_log.finest('Attempting secure connection to ${srv.target}:${srv.port}...');
|
||||||
|
_ignoreSocketClosure = true;
|
||||||
|
_socket = await SecureSocket.connect(
|
||||||
|
srv.target,
|
||||||
|
srv.port,
|
||||||
|
timeout: const Duration(seconds: 5),
|
||||||
|
supportedProtocols: const [ xmppClientALPNId ],
|
||||||
|
onBadCertificate: (cert) => _onBadCertificate(cert, domain),
|
||||||
|
);
|
||||||
|
|
||||||
|
_ignoreSocketClosure = false;
|
||||||
|
_secure = true;
|
||||||
|
_log.finest('Success!');
|
||||||
|
return true;
|
||||||
|
} on SocketException catch(e) {
|
||||||
|
_log.finest('Failure! $e');
|
||||||
|
_ignoreSocketClosure = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _rfc6120Connect(String domain) async {
|
||||||
|
// TODO(Unknown): Maybe do DNSSEC one day
|
||||||
|
final results = await MoxdnsPlugin.srvQuery('_xmpp-client._tcp.$domain', false);
|
||||||
|
results.sort(srvRecordSortComparator);
|
||||||
|
|
||||||
|
for (final srv in results) {
|
||||||
|
try {
|
||||||
|
_log.finest('Attempting connection to ${srv.target}:${srv.port}...');
|
||||||
|
_ignoreSocketClosure = true;
|
||||||
|
_socket = await Socket.connect(
|
||||||
|
srv.target,
|
||||||
|
srv.port,
|
||||||
|
timeout: const Duration(seconds: 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
_ignoreSocketClosure = false;
|
||||||
|
_log.finest('Success!');
|
||||||
|
return true;
|
||||||
|
} on SocketException catch(e) {
|
||||||
|
_log.finest('Failure! $e');
|
||||||
|
_ignoreSocketClosure = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _rfc6120FallbackConnect(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to [host] with port [port] and returns true if the connection
|
||||||
|
/// was successfully established. Does not setup the streams as this has
|
||||||
|
/// to be done by the caller.
|
||||||
|
Future<bool> _hostPortConnect(String host, int port) async {
|
||||||
|
try {
|
||||||
|
_log.finest('Attempting fallback connection to $host:$port...');
|
||||||
|
_ignoreSocketClosure = true;
|
||||||
|
_socket = await Socket.connect(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
timeout: const Duration(seconds: 5),
|
||||||
|
);
|
||||||
|
_log.finest('Success!');
|
||||||
|
return true;
|
||||||
|
} on SocketException catch(e) {
|
||||||
|
_log.finest('Failure! $e');
|
||||||
|
_ignoreSocketClosure = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to [domain] using the default C2S port of XMPP. Returns
|
||||||
|
/// true if the connection was successful. Does not setup the streams
|
||||||
|
/// as [_rfc6120FallbackConnect] should only be called from
|
||||||
|
/// [_rfc6120Connect], which already sets the streams up on a successful
|
||||||
|
/// connection.
|
||||||
|
Future<bool> _rfc6120FallbackConnect(String domain) async {
|
||||||
|
return _hostPortConnect(domain, 5222);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> secure(String domain) async {
|
||||||
|
if (_secure) {
|
||||||
|
_log.warning('Connection is already marked as secure. Doing nothing');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_socket == null) {
|
||||||
|
_log.severe('Failed to secure socket since _socket is null');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ignoreSocketClosure = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
_socket = await SecureSocket.secure(
|
||||||
|
_socket!,
|
||||||
|
supportedProtocols: const [ xmppClientALPNId ],
|
||||||
|
onBadCertificate: (cert) => _onBadCertificate(cert, domain),
|
||||||
|
);
|
||||||
|
|
||||||
|
_secure = true;
|
||||||
|
_ignoreSocketClosure = false;
|
||||||
|
_setupStreams();
|
||||||
|
return true;
|
||||||
|
} on SocketException {
|
||||||
|
_ignoreSocketClosure = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupStreams() {
|
||||||
|
if (_socket == null) {
|
||||||
|
_log.severe('Failed to setup streams as _socket is null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_socketSubscription = _socket!.listen(
|
||||||
|
(List<int> event) {
|
||||||
|
final data = utf8.decode(event);
|
||||||
|
if (_logData) {
|
||||||
|
_log.finest('<== $data');
|
||||||
|
}
|
||||||
|
_dataStream.add(data);
|
||||||
|
},
|
||||||
|
onError: (Object error) {
|
||||||
|
_log.severe(error.toString());
|
||||||
|
_eventStream.add(XmppSocketErrorEvent(error));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// ignore: implicit_dynamic_parameter
|
||||||
|
_socket!.done.then((_) {
|
||||||
|
if (!_ignoreSocketClosure) {
|
||||||
|
_eventStream.add(XmppSocketClosureEvent());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> connect(String domain, { String? host, int? port }) async {
|
||||||
|
_ignoreSocketClosure = false;
|
||||||
|
_secure = false;
|
||||||
|
|
||||||
|
// Connection order:
|
||||||
|
// 1. host:port, if given
|
||||||
|
// 2. XEP-0368
|
||||||
|
// 3. RFC 6120
|
||||||
|
// 4. RFC 6120 fallback
|
||||||
|
|
||||||
|
if (host != null && port != null) {
|
||||||
|
_log.finest('Specific host and port given');
|
||||||
|
if (await _hostPortConnect(host, port)) {
|
||||||
|
_setupStreams();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await _xep368Connect(domain)) {
|
||||||
|
_setupStreams();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: _rfc6120Connect already attempts the fallback
|
||||||
|
if (await _rfc6120Connect(domain)) {
|
||||||
|
_setupStreams();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() {
|
||||||
|
if (_socketSubscription != null) {
|
||||||
|
_log.finest('Closing socket subscription');
|
||||||
|
_socketSubscription!.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_socket == null) {
|
||||||
|
_log.warning('Failed to close socket since _socket is null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ignoreSocketClosure = true;
|
||||||
|
try {
|
||||||
|
_socket!.close();
|
||||||
|
} catch(e) {
|
||||||
|
_log.warning('Closing socket threw exception: $e');
|
||||||
|
}
|
||||||
|
_ignoreSocketClosure = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> getDataStream() => _dataStream.stream.asBroadcastStream();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<XmppSocketEvent> getEventStream() => _eventStream.stream.asBroadcastStream();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void write(Object? data, { String? redact }) {
|
||||||
|
if (_socket == null) {
|
||||||
|
_log.severe('Failed to write to socket as _socket is null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data != null && data is String && _logData) {
|
||||||
|
if (redact != null) {
|
||||||
|
_log.finest('**> $redact');
|
||||||
|
} else {
|
||||||
|
_log.finest('==> $data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_socket!.write(data);
|
||||||
|
} on SocketException catch (e) {
|
||||||
|
_log.severe(e);
|
||||||
|
_eventStream.add(XmppSocketErrorEvent(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void prepareDisconnect() {
|
||||||
|
_ignoreSocketClosure = true;
|
||||||
|
}
|
||||||
|
}*/
|
131
moxxmpp/lib/src/stanza.dart
Normal file
131
moxxmpp/lib/src/stanza.dart
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
class Stanza extends XMLNode {
|
||||||
|
|
||||||
|
Stanza({ this.to, this.from, this.type, this.id, List<XMLNode> children = const [], required String tag, Map<String, String> attributes = const {} }) : super(
|
||||||
|
tag: tag,
|
||||||
|
attributes: <String, dynamic>{
|
||||||
|
...attributes,
|
||||||
|
...type != null ? <String, dynamic>{ 'type': type } : <String, dynamic>{},
|
||||||
|
...id != null ? <String, dynamic>{ 'id': id } : <String, dynamic>{},
|
||||||
|
...to != null ? <String, dynamic>{ 'to': to } : <String, dynamic>{},
|
||||||
|
...from != null ? <String, dynamic>{ 'from': from } : <String, dynamic>{},
|
||||||
|
'xmlns': stanzaXmlns
|
||||||
|
},
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
|
||||||
|
factory Stanza.iq({ String? to, String? from, String? type, String? id, List<XMLNode> children = const [], Map<String, String>? attributes = const {} }) {
|
||||||
|
return Stanza(
|
||||||
|
tag: 'iq',
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
id: id,
|
||||||
|
type: type,
|
||||||
|
attributes: <String, String>{
|
||||||
|
...attributes!,
|
||||||
|
'xmlns': stanzaXmlns
|
||||||
|
},
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Stanza.presence({ String? to, String? from, String? type, String? id, List<XMLNode> children = const [], Map<String, String>? attributes = const {} }) {
|
||||||
|
return Stanza(
|
||||||
|
tag: 'presence',
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
id: id,
|
||||||
|
type: type,
|
||||||
|
attributes: <String, String>{
|
||||||
|
...attributes!,
|
||||||
|
'xmlns': stanzaXmlns
|
||||||
|
},
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
factory Stanza.message({ String? to, String? from, String? type, String? id, List<XMLNode> children = const [], Map<String, String>? attributes = const {} }) {
|
||||||
|
return Stanza(
|
||||||
|
tag: 'message',
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
id: id,
|
||||||
|
type: type,
|
||||||
|
attributes: <String, String>{
|
||||||
|
...attributes!,
|
||||||
|
'xmlns': stanzaXmlns
|
||||||
|
},
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Stanza.fromXMLNode(XMLNode node) {
|
||||||
|
return Stanza(
|
||||||
|
to: node.attributes['to'] as String?,
|
||||||
|
from: node.attributes['from'] as String?,
|
||||||
|
id: node.attributes['id'] as String?,
|
||||||
|
tag: node.tag,
|
||||||
|
type: node.attributes['type'] as String?,
|
||||||
|
children: node.children,
|
||||||
|
// TODO(Unknown): Remove to, from, id, and type
|
||||||
|
// TODO(Unknown): Not sure if this is the correct way to approach this
|
||||||
|
attributes: node.attributes
|
||||||
|
.map<String, String>((String key, dynamic value) {
|
||||||
|
return MapEntry(key, value.toString());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? to;
|
||||||
|
String? from;
|
||||||
|
String? type;
|
||||||
|
String? id;
|
||||||
|
|
||||||
|
Stanza copyWith({ String? id, String? from, String? to, String? type, List<XMLNode>? children }) {
|
||||||
|
return Stanza(
|
||||||
|
tag: tag,
|
||||||
|
to: to ?? this.to,
|
||||||
|
from: from ?? this.from,
|
||||||
|
id: id ?? this.id,
|
||||||
|
type: type ?? this.type,
|
||||||
|
children: children ?? this.children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Stanza reply({ List<XMLNode> children = const [] }) {
|
||||||
|
return copyWith(
|
||||||
|
from: attributes['to'] as String?,
|
||||||
|
to: attributes['from'] as String?,
|
||||||
|
type: tag == 'iq' ? 'result' : attributes['type'] as String?,
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Stanza errorReply(String type, String condition, { String? text }) {
|
||||||
|
return copyWith(
|
||||||
|
from: attributes['to'] as String?,
|
||||||
|
to: attributes['from'] as String?,
|
||||||
|
type: 'error',
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'error',
|
||||||
|
attributes: <String, dynamic>{ 'type': type },
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: condition,
|
||||||
|
xmlns: fullStanzaXmlns,
|
||||||
|
children: text != null ?[
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'text',
|
||||||
|
xmlns: fullStanzaXmlns,
|
||||||
|
text: text,
|
||||||
|
)
|
||||||
|
] : [],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
136
moxxmpp/lib/src/stringxml.dart
Normal file
136
moxxmpp/lib/src/stringxml.dart
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
|
class XMLNode {
|
||||||
|
|
||||||
|
XMLNode({
|
||||||
|
required this.tag,
|
||||||
|
this.attributes = const <String, dynamic>{},
|
||||||
|
this.children = const [],
|
||||||
|
this.closeTag = true,
|
||||||
|
this.text,
|
||||||
|
this.isDeclaration = false,
|
||||||
|
});
|
||||||
|
XMLNode.xmlns({
|
||||||
|
required this.tag,
|
||||||
|
required String xmlns,
|
||||||
|
Map<String, String> attributes = const <String, String>{},
|
||||||
|
this.children = const [],
|
||||||
|
this.closeTag = true,
|
||||||
|
this.text,
|
||||||
|
}) : attributes = <String, String>{ 'xmlns': xmlns, ...attributes }, isDeclaration = false;
|
||||||
|
/// Because this API is better ;)
|
||||||
|
/// Don't use in production. Just for testing
|
||||||
|
factory XMLNode.fromXmlElement(XmlElement element) {
|
||||||
|
final attributes = <String, String>{};
|
||||||
|
|
||||||
|
for (final attribute in element.attributes) {
|
||||||
|
attributes[attribute.name.qualified] = attribute.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.childElements.isEmpty) {
|
||||||
|
return XMLNode(
|
||||||
|
tag: element.name.qualified,
|
||||||
|
attributes: attributes,
|
||||||
|
text: element.innerText,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return XMLNode(
|
||||||
|
tag: element.name.qualified,
|
||||||
|
attributes: attributes,
|
||||||
|
children: element.childElements.toList().map(XMLNode.fromXmlElement).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Just for testing purposes
|
||||||
|
factory XMLNode.fromString(String str) {
|
||||||
|
return XMLNode.fromXmlElement(
|
||||||
|
XmlDocument.parse(str).firstElementChild!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final String tag;
|
||||||
|
Map<String, dynamic> attributes;
|
||||||
|
List<XMLNode> children;
|
||||||
|
bool closeTag;
|
||||||
|
String? text;
|
||||||
|
bool isDeclaration;
|
||||||
|
|
||||||
|
/// Adds a child to this node.
|
||||||
|
void addChild(XMLNode child) {
|
||||||
|
children.add(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the attributes of the node into "attr1=\"value\" attr2=...".
|
||||||
|
String renderAttributes() {
|
||||||
|
return attributes.keys.map((String key) {
|
||||||
|
final dynamic value = attributes[key];
|
||||||
|
assert(value is String || value is int, 'XML values must either be string or int');
|
||||||
|
if (value is String) {
|
||||||
|
return "$key='$value'";
|
||||||
|
} else {
|
||||||
|
return '$key=$value';
|
||||||
|
}
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the entire node, including its children, into an XML string.
|
||||||
|
String toXml() {
|
||||||
|
final decl = isDeclaration ? '?' : '';
|
||||||
|
if (children.isEmpty) {
|
||||||
|
if (text != null && text!.isNotEmpty) {
|
||||||
|
final attrString = attributes.isEmpty ? '' : ' ${renderAttributes()}';
|
||||||
|
return '<$tag$attrString>$text</$tag>';
|
||||||
|
} else {
|
||||||
|
return '<$decl$tag ${renderAttributes()}${closeTag ? " />" : "$decl>"}';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final childXml = children.map((child) => child.toXml()).join();
|
||||||
|
final xml = '<$decl$tag ${renderAttributes()}$decl>$childXml';
|
||||||
|
return xml + (closeTag ? '</$tag>' : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the first child for which [test] returns true. If none is found, returns
|
||||||
|
/// null.
|
||||||
|
XMLNode? _firstTag(bool Function(XMLNode) test) {
|
||||||
|
try {
|
||||||
|
return children.firstWhere(test);
|
||||||
|
} catch(e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the first xml node that matches the description:
|
||||||
|
/// - node's tag is equal to [tag]
|
||||||
|
/// - (optional) node's xmlns attribute is equal to [xmlns]
|
||||||
|
/// Returns null if none is found.
|
||||||
|
XMLNode? firstTag(String tag, { String? xmlns}) {
|
||||||
|
return _firstTag((node) {
|
||||||
|
if (xmlns != null) {
|
||||||
|
return node.tag == tag && node.attributes['xmlns'] == xmlns;
|
||||||
|
}
|
||||||
|
|
||||||
|
return node.tag == tag;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the first child whose xmlns attribute is equal to [xmlns]. Returns null
|
||||||
|
/// if none is found.
|
||||||
|
XMLNode? firstTagByXmlns(String xmlns) {
|
||||||
|
return _firstTag((node) {
|
||||||
|
return node.attributes['xmlns'] == xmlns;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all children whose tag is equal to [tag].
|
||||||
|
List<XMLNode> findTags(String tag, { String? xmlns }) {
|
||||||
|
return children.where((element) {
|
||||||
|
final xmlnsMatches = xmlns != null ? element.attributes['xmlns'] == xmlns : true;
|
||||||
|
return element.tag == tag && xmlnsMatches;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the inner text of the node. If none is set, returns the "".
|
||||||
|
String innerText() {
|
||||||
|
return text ?? '';
|
||||||
|
}
|
||||||
|
}
|
19
moxxmpp/lib/src/types/error.dart
Normal file
19
moxxmpp/lib/src/types/error.dart
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/// A wrapper class that can be used to indicate that a function may return a valid
|
||||||
|
/// instance of [T] but may also fail.
|
||||||
|
/// The way [MayFail] is intended to be used to to have function specific - or application
|
||||||
|
/// specific - error codes that can be either handled by code or be translated into a
|
||||||
|
/// localised error message for the user.
|
||||||
|
class MayFail<T> {
|
||||||
|
|
||||||
|
MayFail({ this.result, this.errorCode });
|
||||||
|
MayFail.success(this.result);
|
||||||
|
MayFail.failure(this.errorCode);
|
||||||
|
T? result;
|
||||||
|
int? errorCode;
|
||||||
|
|
||||||
|
bool isError() => result == null && errorCode != null;
|
||||||
|
|
||||||
|
T getValue() => result!;
|
||||||
|
|
||||||
|
int getErrorCode() => errorCode!;
|
||||||
|
}
|
13
moxxmpp/lib/src/types/result.dart
Normal file
13
moxxmpp/lib/src/types/result.dart
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/// Class that is supposed to by used with a state type S and a value type V.
|
||||||
|
/// The state indicates if an action was successful or not, while the value
|
||||||
|
/// type indicates the return value, i.e. a result in a computation or the
|
||||||
|
/// actual error description.
|
||||||
|
class Result<S, V> {
|
||||||
|
|
||||||
|
Result(S state, V value) : _state = state, _value = value;
|
||||||
|
final S _state;
|
||||||
|
final V _value;
|
||||||
|
|
||||||
|
S getState() => _state;
|
||||||
|
V getValue() => _value;
|
||||||
|
}
|
13
moxxmpp/lib/src/types/resultv2.dart
Normal file
13
moxxmpp/lib/src/types/resultv2.dart
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
class Result<T, V> {
|
||||||
|
|
||||||
|
const Result(this._data) : assert(_data is T || _data is V, 'Invalid data type: Must be either $T or $V');
|
||||||
|
final dynamic _data;
|
||||||
|
|
||||||
|
bool isType<S>() => _data is S;
|
||||||
|
|
||||||
|
S get<S>() {
|
||||||
|
assert(_data is S, 'Data is not $S');
|
||||||
|
|
||||||
|
return _data as S;
|
||||||
|
}
|
||||||
|
}
|
54
moxxmpp/lib/src/xeps/staging/extensible_file_thumbnails.dart
Normal file
54
moxxmpp/lib/src/xeps/staging/extensible_file_thumbnails.dart
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
/// NOTE: Specified by https://codeberg.org/moxxy/custom-xeps/src/branch/master/xep-xxxx-extensible-file-thumbnails.md
|
||||||
|
|
||||||
|
const fileThumbnailsXmlns = 'proto:urn:xmpp:eft:0';
|
||||||
|
const blurhashThumbnailType = '$fileThumbnailsXmlns:blurhash';
|
||||||
|
|
||||||
|
abstract class Thumbnail {}
|
||||||
|
|
||||||
|
class BlurhashThumbnail extends Thumbnail {
|
||||||
|
|
||||||
|
BlurhashThumbnail(this.hash);
|
||||||
|
final String hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
Thumbnail? parseFileThumbnailElement(XMLNode node) {
|
||||||
|
assert(node.attributes['xmlns'] == fileThumbnailsXmlns, 'Invalid element xmlns');
|
||||||
|
assert(node.tag == 'file-thumbnail', 'Invalid element name');
|
||||||
|
|
||||||
|
switch (node.attributes['type']!) {
|
||||||
|
case blurhashThumbnailType: {
|
||||||
|
final hash = node.firstTag('blurhash')!.innerText();
|
||||||
|
return BlurhashThumbnail(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
XMLNode? _fromThumbnail(Thumbnail thumbnail) {
|
||||||
|
if (thumbnail is BlurhashThumbnail) {
|
||||||
|
return XMLNode(
|
||||||
|
tag: 'blurhash',
|
||||||
|
text: thumbnail.hash,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
XMLNode constructFileThumbnailElement(Thumbnail thumbnail) {
|
||||||
|
final node = _fromThumbnail(thumbnail)!;
|
||||||
|
var type = '';
|
||||||
|
if (thumbnail is BlurhashThumbnail) {
|
||||||
|
type = blurhashThumbnailType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return XMLNode.xmlns(
|
||||||
|
tag: 'file-thumbnail',
|
||||||
|
xmlns: fileThumbnailsXmlns,
|
||||||
|
attributes: { 'type': type },
|
||||||
|
children: [ node ],
|
||||||
|
);
|
||||||
|
}
|
72
moxxmpp/lib/src/xeps/staging/file_upload_notification.dart
Normal file
72
moxxmpp/lib/src/xeps/staging/file_upload_notification.dart
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0446.dart';
|
||||||
|
|
||||||
|
/// NOTE: Specified by https://github.com/PapaTutuWawa/custom-xeps/blob/master/xep-xxxx-file-upload-notifications.md
|
||||||
|
|
||||||
|
const fileUploadNotificationXmlns = 'proto:urn:xmpp:fun:0';
|
||||||
|
|
||||||
|
class FileUploadNotificationManager extends XmppManagerBase {
|
||||||
|
FileUploadNotificationManager() : super();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => fileUploadNotificationManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'FileUploadNotificationManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
tagName: 'file-upload',
|
||||||
|
tagXmlns: fileUploadNotificationXmlns,
|
||||||
|
callback: _onFileUploadNotificationReceived,
|
||||||
|
priority: -99,
|
||||||
|
),
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
tagName: 'replaces',
|
||||||
|
tagXmlns: fileUploadNotificationXmlns,
|
||||||
|
callback: _onFileUploadNotificationReplacementReceived,
|
||||||
|
priority: -99,
|
||||||
|
),
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
tagName: 'cancelled',
|
||||||
|
tagXmlns: fileUploadNotificationXmlns,
|
||||||
|
callback: _onFileUploadNotificationCancellationReceived,
|
||||||
|
priority: -99,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onFileUploadNotificationReceived(Stanza message, StanzaHandlerData state) async {
|
||||||
|
final funElement = message.firstTag('file-upload', xmlns: fileUploadNotificationXmlns)!;
|
||||||
|
return state.copyWith(
|
||||||
|
fun: FileMetadataData.fromXML(
|
||||||
|
funElement.firstTag('file', xmlns: fileMetadataXmlns)!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onFileUploadNotificationReplacementReceived(Stanza message, StanzaHandlerData state) async {
|
||||||
|
final element = message.firstTag('replaces', xmlns: fileUploadNotificationXmlns)!;
|
||||||
|
return state.copyWith(
|
||||||
|
funReplacement: element.attributes['id']! as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onFileUploadNotificationCancellationReceived(Stanza message, StanzaHandlerData state) async {
|
||||||
|
final element = message.firstTag('cancels', xmlns: fileUploadNotificationXmlns)!;
|
||||||
|
return state.copyWith(
|
||||||
|
funCancellation: element.attributes['id']! as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
147
moxxmpp/lib/src/xeps/xep_0004.dart
Normal file
147
moxxmpp/lib/src/xeps/xep_0004.dart
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
class DataFormOption {
|
||||||
|
|
||||||
|
const DataFormOption({ required this.value, this.label });
|
||||||
|
final String? label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
XMLNode toXml() {
|
||||||
|
return XMLNode(
|
||||||
|
tag: 'option',
|
||||||
|
attributes: label != null ? <String, dynamic>{ 'label': label } : <String, dynamic>{},
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'value',
|
||||||
|
text: value,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DataFormField {
|
||||||
|
|
||||||
|
const DataFormField({
|
||||||
|
required this.options,
|
||||||
|
required this.values,
|
||||||
|
required this.isRequired,
|
||||||
|
this.varAttr,
|
||||||
|
this.type,
|
||||||
|
this.description,
|
||||||
|
this.label,
|
||||||
|
});
|
||||||
|
final String? description;
|
||||||
|
final bool isRequired;
|
||||||
|
final List<String> values;
|
||||||
|
final List<DataFormOption> options;
|
||||||
|
final String? type;
|
||||||
|
final String? varAttr;
|
||||||
|
final String? label;
|
||||||
|
|
||||||
|
XMLNode toXml() {
|
||||||
|
return XMLNode(
|
||||||
|
tag: 'field',
|
||||||
|
attributes: <String, dynamic>{
|
||||||
|
...varAttr != null ? <String, dynamic>{ 'var': varAttr } : <String, dynamic>{},
|
||||||
|
...type != null ? <String, dynamic>{ 'type': type } : <String, dynamic>{},
|
||||||
|
...label != null ? <String, dynamic>{ 'label': label } : <String, dynamic>{}
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
...description != null ? [XMLNode(tag: 'desc', text: description)] : [],
|
||||||
|
...isRequired ? [XMLNode(tag: 'required')] : [],
|
||||||
|
...values.map((value) => XMLNode(tag: 'value', text: value)).toList(),
|
||||||
|
...options.map((option) => option.toXml())
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DataForm {
|
||||||
|
|
||||||
|
const DataForm({
|
||||||
|
required this.type,
|
||||||
|
required this.instructions,
|
||||||
|
required this.fields,
|
||||||
|
required this.reported,
|
||||||
|
required this.items,
|
||||||
|
this.title,
|
||||||
|
});
|
||||||
|
final String type;
|
||||||
|
final String? title;
|
||||||
|
final List<String> instructions;
|
||||||
|
final List<DataFormField> fields;
|
||||||
|
final List<DataFormField> reported;
|
||||||
|
final List<List<DataFormField>> items;
|
||||||
|
|
||||||
|
DataFormField? getFieldByVar(String varAttr) {
|
||||||
|
return firstWhereOrNull(fields, (field) => field.varAttr == varAttr);
|
||||||
|
}
|
||||||
|
|
||||||
|
XMLNode toXml() {
|
||||||
|
return XMLNode.xmlns(
|
||||||
|
tag: 'x',
|
||||||
|
xmlns: dataFormsXmlns,
|
||||||
|
attributes: {
|
||||||
|
'type': type
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
...instructions.map((i) => XMLNode(tag: 'instruction', text: i)).toList(),
|
||||||
|
...title != null ? [XMLNode(tag: 'title', text: title)] : [],
|
||||||
|
...fields.map((field) => field.toXml()).toList(),
|
||||||
|
...reported.map((report) => report.toXml()).toList(),
|
||||||
|
...items.map((item) => XMLNode(
|
||||||
|
tag: 'item',
|
||||||
|
children: item.map((i) => i.toXml()).toList(),
|
||||||
|
),).toList(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DataFormOption _parseDataFormOption(XMLNode option) {
|
||||||
|
return DataFormOption(
|
||||||
|
label: option.attributes['label'] as String?,
|
||||||
|
value: option.firstTag('value')!.innerText(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DataFormField _parseDataFormField(XMLNode field) {
|
||||||
|
final desc = field.firstTag('desc')?.innerText();
|
||||||
|
final isRequired = field.firstTag('required') != null;
|
||||||
|
final values = field.findTags('value').map((i) => i.innerText()).toList();
|
||||||
|
final options = field.findTags('option').map(_parseDataFormOption).toList();
|
||||||
|
|
||||||
|
return DataFormField(
|
||||||
|
varAttr: field.attributes['var'] as String?,
|
||||||
|
type: field.attributes['type'] as String?,
|
||||||
|
options: options,
|
||||||
|
values: values,
|
||||||
|
isRequired: isRequired,
|
||||||
|
description: desc,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a Data Form declaration.
|
||||||
|
DataForm parseDataForm(XMLNode x) {
|
||||||
|
assert(x.attributes['xmlns'] == dataFormsXmlns, 'Invalid element xmlns');
|
||||||
|
assert(x.tag == 'x', 'Invalid element name');
|
||||||
|
|
||||||
|
final type = x.attributes['type']! as String;
|
||||||
|
final title = x.firstTag('title')?.innerText();
|
||||||
|
final instructions = x.findTags('instructions').map((i) => i.innerText()).toList();
|
||||||
|
final fields = x.findTags('field').map(_parseDataFormField).toList();
|
||||||
|
final reported = x.firstTag('reported')?.findTags('field').map((i) => _parseDataFormField(i.firstTag('field')!)).toList() ?? [];
|
||||||
|
final items = x.findTags('item').map((i) => i.findTags('field').map(_parseDataFormField).toList()).toList();
|
||||||
|
|
||||||
|
return DataForm(
|
||||||
|
type: type,
|
||||||
|
instructions: instructions,
|
||||||
|
fields: fields,
|
||||||
|
reported: reported,
|
||||||
|
items: items,
|
||||||
|
title: title,
|
||||||
|
);
|
||||||
|
}
|
7
moxxmpp/lib/src/xeps/xep_0030/errors.dart
Normal file
7
moxxmpp/lib/src/xeps/xep_0030/errors.dart
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
abstract class DiscoError {}
|
||||||
|
|
||||||
|
class UnknownDiscoError extends DiscoError {}
|
||||||
|
|
||||||
|
class InvalidResponseDiscoError extends DiscoError {}
|
||||||
|
|
||||||
|
class ErrorResponseDiscoError extends DiscoError {}
|
25
moxxmpp/lib/src/xeps/xep_0030/helpers.dart
Normal file
25
moxxmpp/lib/src/xeps/xep_0030/helpers.dart
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
// TODO(PapaTutuWawa): Move types into types.dart
|
||||||
|
|
||||||
|
Stanza buildDiscoInfoQueryStanza(String entity, String? node) {
|
||||||
|
return Stanza.iq(to: entity, type: 'get', children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'query',
|
||||||
|
xmlns: discoInfoXmlns,
|
||||||
|
attributes: node != null ? { 'node': node } : {},
|
||||||
|
)
|
||||||
|
],);
|
||||||
|
}
|
||||||
|
|
||||||
|
Stanza buildDiscoItemsQueryStanza(String entity, { String? node }) {
|
||||||
|
return Stanza.iq(to: entity, type: 'get', children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'query',
|
||||||
|
xmlns: discoItemsXmlns,
|
||||||
|
attributes: node != null ? { 'node': node } : {},
|
||||||
|
)
|
||||||
|
],);
|
||||||
|
}
|
46
moxxmpp/lib/src/xeps/xep_0030/types.dart
Normal file
46
moxxmpp/lib/src/xeps/xep_0030/types.dart
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0004.dart';
|
||||||
|
|
||||||
|
class Identity {
|
||||||
|
|
||||||
|
const Identity({ required this.category, required this.type, this.name, this.lang });
|
||||||
|
final String category;
|
||||||
|
final String type;
|
||||||
|
final String? name;
|
||||||
|
final String? lang;
|
||||||
|
|
||||||
|
XMLNode toXMLNode() {
|
||||||
|
return XMLNode(
|
||||||
|
tag: 'identity',
|
||||||
|
attributes: <String, dynamic>{
|
||||||
|
'category': category,
|
||||||
|
'type': type,
|
||||||
|
'name': name,
|
||||||
|
...lang == null ? <String, dynamic>{} : <String, dynamic>{ 'xml:lang': lang }
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiscoInfo {
|
||||||
|
|
||||||
|
const DiscoInfo(
|
||||||
|
this.features,
|
||||||
|
this.identities,
|
||||||
|
this.extendedInfo,
|
||||||
|
this.jid,
|
||||||
|
);
|
||||||
|
final List<String> features;
|
||||||
|
final List<Identity> identities;
|
||||||
|
final List<DataForm> extendedInfo;
|
||||||
|
final JID jid;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiscoItem {
|
||||||
|
|
||||||
|
const DiscoItem({ required this.jid, this.node, this.name });
|
||||||
|
final String jid;
|
||||||
|
final String? node;
|
||||||
|
final String? name;
|
||||||
|
}
|
439
moxxmpp/lib/src/xeps/xep_0030/xep_0030.dart
Normal file
439
moxxmpp/lib/src/xeps/xep_0030/xep_0030.dart
Normal file
@ -0,0 +1,439 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:meta/meta.dart';
|
||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/presence.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/types/resultv2.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0004.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/errors.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/helpers.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/types.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0115.dart';
|
||||||
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class DiscoCacheKey {
|
||||||
|
|
||||||
|
const DiscoCacheKey(this.jid, this.node);
|
||||||
|
final String jid;
|
||||||
|
final String? node;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is DiscoCacheKey && jid == other.jid && node == other.node;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => jid.hashCode ^ node.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiscoManager extends XmppManagerBase {
|
||||||
|
|
||||||
|
DiscoManager()
|
||||||
|
: _features = List.empty(growable: true),
|
||||||
|
_capHashCache = {},
|
||||||
|
_capHashInfoCache = {},
|
||||||
|
_discoInfoCache = {},
|
||||||
|
_runningInfoQueries = {},
|
||||||
|
_cacheLock = Lock(),
|
||||||
|
super();
|
||||||
|
/// Our features
|
||||||
|
final List<String> _features;
|
||||||
|
|
||||||
|
// Map full JID to Capability hashes
|
||||||
|
final Map<String, CapabilityHashInfo> _capHashCache;
|
||||||
|
// Map capability hash to the disco info
|
||||||
|
final Map<String, DiscoInfo> _capHashInfoCache;
|
||||||
|
// Map full JID to Disco Info
|
||||||
|
final Map<DiscoCacheKey, DiscoInfo> _discoInfoCache;
|
||||||
|
// Mapping the full JID to a list of running requests
|
||||||
|
final Map<DiscoCacheKey, List<Completer<Result<DiscoError, DiscoInfo>>>> _runningInfoQueries;
|
||||||
|
// Cache lock
|
||||||
|
final Lock _cacheLock;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
bool hasInfoQueriesRunning() => _runningInfoQueries.isNotEmpty;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
List<Completer<Result<DiscoError, DiscoInfo>>> getRunningInfoQueries(DiscoCacheKey key) => _runningInfoQueries[key]!;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
tagName: 'query',
|
||||||
|
tagXmlns: discoInfoXmlns,
|
||||||
|
stanzaTag: 'iq',
|
||||||
|
callback: _onDiscoInfoRequest,
|
||||||
|
),
|
||||||
|
StanzaHandler(
|
||||||
|
tagName: 'query',
|
||||||
|
tagXmlns: discoItemsXmlns,
|
||||||
|
stanzaTag: 'iq',
|
||||||
|
callback: _onDiscoItemsRequest,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => discoManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'DiscoManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> getDiscoFeatures() => [ discoInfoXmlns, discoItemsXmlns ];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onXmppEvent(XmppEvent event) async {
|
||||||
|
if (event is PresenceReceivedEvent) {
|
||||||
|
await _onPresence(event.jid, event.presence);
|
||||||
|
} else if (event is StreamResumeFailedEvent) {
|
||||||
|
await _cacheLock.synchronized(() async {
|
||||||
|
// Clear the cache
|
||||||
|
_discoInfoCache.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a list of features to the possible disco info response.
|
||||||
|
/// This function only adds features that are not already present in the disco features.
|
||||||
|
void addDiscoFeatures(List<String> features) {
|
||||||
|
for (final feat in features) {
|
||||||
|
if (!_features.contains(feat)) {
|
||||||
|
_features.add(feat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onPresence(JID from, Stanza presence) async {
|
||||||
|
final c = presence.firstTag('c', xmlns: capsXmlns);
|
||||||
|
if (c == null) return;
|
||||||
|
|
||||||
|
final info = CapabilityHashInfo(
|
||||||
|
c.attributes['ver']! as String,
|
||||||
|
c.attributes['node']! as String,
|
||||||
|
c.attributes['hash']! as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if we already know of that cache
|
||||||
|
var cached = false;
|
||||||
|
await _cacheLock.synchronized(() async {
|
||||||
|
if (!_capHashCache.containsKey(info.ver)) {
|
||||||
|
cached = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (cached) return;
|
||||||
|
|
||||||
|
// Request the cap hash
|
||||||
|
logger.finest("Received capability hash we don't know about. Requesting it...");
|
||||||
|
final result = await discoInfoQuery(from.toString(), node: '${info.node}#${info.ver}');
|
||||||
|
if (result.isType<DiscoError>()) return;
|
||||||
|
|
||||||
|
await _cacheLock.synchronized(() async {
|
||||||
|
_capHashCache[from.toString()] = info;
|
||||||
|
_capHashInfoCache[info.ver] = result.get<DiscoInfo>();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the list of disco features registered.
|
||||||
|
List<String> getRegisteredDiscoFeatures() => _features;
|
||||||
|
|
||||||
|
/// May be overriden. Specifies the identities which will be returned in a disco info response.
|
||||||
|
List<Identity> getIdentities() => const [ Identity(category: 'client', type: 'pc', name: 'moxxmpp', lang: 'en') ];
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onDiscoInfoRequest(Stanza stanza, StanzaHandlerData state) async {
|
||||||
|
if (stanza.type != 'get') return state;
|
||||||
|
|
||||||
|
final presence = getAttributes().getManagerById(presenceManager)! as PresenceManager;
|
||||||
|
final query = stanza.firstTag('query')!;
|
||||||
|
final node = query.attributes['node'] as String?;
|
||||||
|
final capHash = await presence.getCapabilityHash();
|
||||||
|
final isCapabilityNode = node == 'http://moxxy.im#$capHash';
|
||||||
|
|
||||||
|
if (!isCapabilityNode && node != null) {
|
||||||
|
await getAttributes().sendStanza(Stanza.iq(
|
||||||
|
to: stanza.from,
|
||||||
|
from: stanza.to,
|
||||||
|
id: stanza.id,
|
||||||
|
type: 'error',
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'query',
|
||||||
|
// TODO(PapaTutuWawa): Why are we copying the xmlns?
|
||||||
|
xmlns: query.attributes['xmlns']! as String,
|
||||||
|
attributes: <String, String>{
|
||||||
|
'node': node
|
||||||
|
},
|
||||||
|
),
|
||||||
|
XMLNode(
|
||||||
|
tag: 'error',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'type': 'cancel'
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'not-allowed',
|
||||||
|
xmlns: fullStanzaXmlns,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
,);
|
||||||
|
|
||||||
|
return state.copyWith(done: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await getAttributes().sendStanza(stanza.reply(
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'query',
|
||||||
|
xmlns: discoInfoXmlns,
|
||||||
|
attributes: {
|
||||||
|
...!isCapabilityNode ? {} : {
|
||||||
|
'node': 'http://moxxy.im#$capHash'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
...getIdentities().map((identity) => identity.toXMLNode()).toList(),
|
||||||
|
..._features.map((feat) {
|
||||||
|
return XMLNode(
|
||||||
|
tag: 'feature',
|
||||||
|
attributes: <String, dynamic>{ 'var': feat },
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),);
|
||||||
|
|
||||||
|
return state.copyWith(done: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onDiscoItemsRequest(Stanza stanza, StanzaHandlerData state) async {
|
||||||
|
if (stanza.type != 'get') return state;
|
||||||
|
|
||||||
|
final query = stanza.firstTag('query')!;
|
||||||
|
if (query.attributes['node'] != null) {
|
||||||
|
// TODO(Unknown): Handle the node we specified for XEP-0115
|
||||||
|
await getAttributes().sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
to: stanza.from,
|
||||||
|
from: stanza.to,
|
||||||
|
id: stanza.id,
|
||||||
|
type: 'error',
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'query',
|
||||||
|
// TODO(PapaTutuWawa): Why copy the xmlns?
|
||||||
|
xmlns: query.attributes['xmlns']! as String,
|
||||||
|
attributes: <String, String>{
|
||||||
|
'node': query.attributes['node']! as String,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
XMLNode(
|
||||||
|
tag: 'error',
|
||||||
|
attributes: <String, dynamic>{
|
||||||
|
'type': 'cancel'
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'not-allowed',
|
||||||
|
xmlns: fullStanzaXmlns,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return state.copyWith(done: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await getAttributes().sendStanza(
|
||||||
|
stanza.reply(
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'query',
|
||||||
|
xmlns: discoItemsXmlns,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return state.copyWith(done: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _exitDiscoInfoCriticalSection(DiscoCacheKey key, Result<DiscoError, DiscoInfo> result) async {
|
||||||
|
return _cacheLock.synchronized(() async {
|
||||||
|
// Complete all futures
|
||||||
|
for (final completer in _runningInfoQueries[key]!) {
|
||||||
|
completer.complete(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to cache if it is a result
|
||||||
|
if (result.isType<DiscoInfo>()) {
|
||||||
|
_discoInfoCache[key] = result.get<DiscoInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from the request cache
|
||||||
|
_runningInfoQueries.remove(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a disco info query to the (full) jid [entity], optionally with node=[node].
|
||||||
|
Future<Result<DiscoError, DiscoInfo>> discoInfoQuery(String entity, { String? node}) async {
|
||||||
|
final cacheKey = DiscoCacheKey(entity, node);
|
||||||
|
DiscoInfo? info;
|
||||||
|
Completer<Result<DiscoError, DiscoInfo>>? completer;
|
||||||
|
await _cacheLock.synchronized(() async {
|
||||||
|
// Check if we already know what the JID supports
|
||||||
|
if (_discoInfoCache.containsKey(cacheKey)) {
|
||||||
|
info = _discoInfoCache[cacheKey];
|
||||||
|
} else {
|
||||||
|
// Is a request running?
|
||||||
|
if (_runningInfoQueries.containsKey(cacheKey)) {
|
||||||
|
completer = Completer();
|
||||||
|
_runningInfoQueries[cacheKey]!.add(completer!);
|
||||||
|
} else {
|
||||||
|
_runningInfoQueries[cacheKey] = List.from(<Completer<DiscoInfo?>>[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (info != null) {
|
||||||
|
return Result<DiscoError, DiscoInfo>(info);
|
||||||
|
} else if (completer != null) {
|
||||||
|
return completer!.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
final stanza = await getAttributes().sendStanza(
|
||||||
|
buildDiscoInfoQueryStanza(entity, node),
|
||||||
|
);
|
||||||
|
final query = stanza.firstTag('query');
|
||||||
|
if (query == null) {
|
||||||
|
final result = Result<DiscoError, DiscoInfo>(InvalidResponseDiscoError());
|
||||||
|
await _exitDiscoInfoCriticalSection(cacheKey, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
final error = stanza.firstTag('error');
|
||||||
|
if (error != null && stanza.attributes['type'] == 'error') {
|
||||||
|
final result = Result<DiscoError, DiscoInfo>(ErrorResponseDiscoError());
|
||||||
|
await _exitDiscoInfoCriticalSection(cacheKey, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
final features = List<String>.empty(growable: true);
|
||||||
|
final identities = List<Identity>.empty(growable: true);
|
||||||
|
|
||||||
|
for (final element in query.children) {
|
||||||
|
if (element.tag == 'feature') {
|
||||||
|
features.add(element.attributes['var']! as String);
|
||||||
|
} else if (element.tag == 'identity') {
|
||||||
|
identities.add(Identity(
|
||||||
|
category: element.attributes['category']! as String,
|
||||||
|
type: element.attributes['type']! as String,
|
||||||
|
name: element.attributes['name'] as String?,
|
||||||
|
),);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = Result<DiscoError, DiscoInfo>(
|
||||||
|
DiscoInfo(
|
||||||
|
features,
|
||||||
|
identities,
|
||||||
|
query.findTags('x', xmlns: dataFormsXmlns).map(parseDataForm).toList(),
|
||||||
|
JID.fromString(stanza.attributes['from']! as String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await _exitDiscoInfoCriticalSection(cacheKey, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a disco items query to the (full) jid [entity], optionally with node=[node].
|
||||||
|
Future<Result<DiscoError, List<DiscoItem>>> discoItemsQuery(String entity, { String? node }) async {
|
||||||
|
final stanza = await getAttributes()
|
||||||
|
.sendStanza(buildDiscoItemsQueryStanza(entity, node: node)) as Stanza;
|
||||||
|
|
||||||
|
final query = stanza.firstTag('query');
|
||||||
|
if (query == null) return Result(InvalidResponseDiscoError());
|
||||||
|
|
||||||
|
final error = stanza.firstTag('error');
|
||||||
|
if (error != null && stanza.type == 'error') {
|
||||||
|
//print("Disco Items error: " + error.toXml());
|
||||||
|
return Result(ErrorResponseDiscoError());
|
||||||
|
}
|
||||||
|
|
||||||
|
final items = query.findTags('item').map((node) => DiscoItem(
|
||||||
|
jid: node.attributes['jid']! as String,
|
||||||
|
node: node.attributes['node'] as String?,
|
||||||
|
name: node.attributes['name'] as String?,
|
||||||
|
),).toList();
|
||||||
|
|
||||||
|
return Result(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queries information about a jid based on its node and capability hash.
|
||||||
|
Future<Result<DiscoError, DiscoInfo>> discoInfoCapHashQuery(String jid, String node, String ver) async {
|
||||||
|
return discoInfoQuery(jid, node: '$node#$ver');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<DiscoError, List<DiscoInfo>>> performDiscoSweep() async {
|
||||||
|
final attrs = getAttributes();
|
||||||
|
final serverJid = attrs.getConnectionSettings().jid.domain;
|
||||||
|
final infoResults = List<DiscoInfo>.empty(growable: true);
|
||||||
|
final result = await discoInfoQuery(serverJid);
|
||||||
|
if (result.isType<DiscoInfo>()) {
|
||||||
|
final info = result.get<DiscoInfo>();
|
||||||
|
logger.finest('Discovered supported server features: ${info.features}');
|
||||||
|
infoResults.add(info);
|
||||||
|
|
||||||
|
attrs.sendEvent(ServerItemDiscoEvent(info));
|
||||||
|
attrs.sendEvent(ServerDiscoDoneEvent());
|
||||||
|
} else {
|
||||||
|
logger.warning('Failed to discover server features');
|
||||||
|
return Result(UnknownDiscoError());
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await discoItemsQuery(serverJid);
|
||||||
|
if (response.isType<List<DiscoItem>>()) {
|
||||||
|
logger.finest('Discovered disco items form $serverJid');
|
||||||
|
|
||||||
|
// Query all items
|
||||||
|
final items = response.get<List<DiscoItem>>();
|
||||||
|
for (final item in items) {
|
||||||
|
logger.finest('Querying info for ${item.jid}...');
|
||||||
|
final itemInfoResult = await discoInfoQuery(item.jid);
|
||||||
|
if (itemInfoResult.isType<DiscoInfo>()) {
|
||||||
|
final itemInfo = itemInfoResult.get<DiscoInfo>();
|
||||||
|
logger.finest('Received info for ${item.jid}');
|
||||||
|
infoResults.add(itemInfo);
|
||||||
|
attrs.sendEvent(ServerItemDiscoEvent(itemInfo));
|
||||||
|
} else {
|
||||||
|
logger.warning('Failed to discover info for ${item.jid}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warning('Failed to discover items of $serverJid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result(infoResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wrapper function around discoInfoQuery: Returns true if the entity with JID
|
||||||
|
/// [entity] supports the disco feature [feature]. If not, returns false.
|
||||||
|
Future<bool> supportsFeature(JID entity, String feature) async {
|
||||||
|
final info = await discoInfoQuery(entity.toString());
|
||||||
|
if (info.isType<DiscoError>()) return false;
|
||||||
|
|
||||||
|
return info.get<DiscoInfo>().features.contains(feature);
|
||||||
|
}
|
||||||
|
}
|
118
moxxmpp/lib/src/xeps/xep_0054.dart
Normal file
118
moxxmpp/lib/src/xeps/xep_0054.dart
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
class VCardPhoto {
|
||||||
|
|
||||||
|
const VCardPhoto({ this.binval });
|
||||||
|
final String? binval;
|
||||||
|
}
|
||||||
|
|
||||||
|
class VCard {
|
||||||
|
|
||||||
|
const VCard({ this.nickname, this.url, this.photo });
|
||||||
|
final String? nickname;
|
||||||
|
final String? url;
|
||||||
|
final VCardPhoto? photo;
|
||||||
|
}
|
||||||
|
|
||||||
|
class VCardManager extends XmppManagerBase {
|
||||||
|
|
||||||
|
VCardManager() : _lastHash = {}, super();
|
||||||
|
final Map<String, String> _lastHash;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => vcardManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'vCardManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'presence',
|
||||||
|
tagName: 'x',
|
||||||
|
tagXmlns: vCardTempUpdate,
|
||||||
|
callback: _onPresence,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
/// In case we get the avatar hash some other way.
|
||||||
|
void setLastHash(String jid, String hash) {
|
||||||
|
_lastHash[jid] = hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onPresence(Stanza presence, StanzaHandlerData state) async {
|
||||||
|
final x = presence.firstTag('x', xmlns: vCardTempUpdate)!;
|
||||||
|
final hash = x.firstTag('photo')!.innerText();
|
||||||
|
|
||||||
|
final from = JID.fromString(presence.from!).toBare().toString();
|
||||||
|
final lastHash = _lastHash[from];
|
||||||
|
if (lastHash != hash) {
|
||||||
|
_lastHash[from] = hash;
|
||||||
|
final vcard = await requestVCard(from);
|
||||||
|
|
||||||
|
if (vcard != null) {
|
||||||
|
final binval = vcard.photo?.binval;
|
||||||
|
if (binval != null) {
|
||||||
|
getAttributes().sendEvent(AvatarUpdatedEvent(jid: from, base64: binval, hash: hash));
|
||||||
|
} else {
|
||||||
|
logger.warning('No avatar data found');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warning('Failed to retrieve vCard for $from');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.copyWith(done: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
VCardPhoto? _parseVCardPhoto(XMLNode? node) {
|
||||||
|
if (node == null) return null;
|
||||||
|
|
||||||
|
return VCardPhoto(
|
||||||
|
binval: node.firstTag('BINVAL')?.innerText(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
VCard _parseVCard(XMLNode vcard) {
|
||||||
|
final nickname = vcard.firstTag('NICKNAME')?.innerText();
|
||||||
|
final url = vcard.firstTag('URL')?.innerText();
|
||||||
|
|
||||||
|
return VCard(
|
||||||
|
url: url,
|
||||||
|
nickname: nickname,
|
||||||
|
photo: _parseVCardPhoto(vcard.firstTag('PHOTO')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<VCard?> requestVCard(String jid) async {
|
||||||
|
final result = await getAttributes().sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
to: jid,
|
||||||
|
type: 'get',
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'vCard',
|
||||||
|
xmlns: vCardTempXmlns,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.attributes['type'] != 'result') return null;
|
||||||
|
final vcard = result.firstTag('vCard', xmlns: vCardTempXmlns);
|
||||||
|
if (vcard == null) return null;
|
||||||
|
|
||||||
|
return _parseVCard(vcard);
|
||||||
|
}
|
||||||
|
}
|
15
moxxmpp/lib/src/xeps/xep_0060/errors.dart
Normal file
15
moxxmpp/lib/src/xeps/xep_0060/errors.dart
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
abstract class PubSubError {}
|
||||||
|
|
||||||
|
class UnknownPubSubError extends PubSubError {}
|
||||||
|
|
||||||
|
class PreconditionsNotMetError extends PubSubError {}
|
||||||
|
|
||||||
|
class MalformedResponseError extends PubSubError {}
|
||||||
|
|
||||||
|
class NoItemReturnedError extends PubSubError {}
|
||||||
|
|
||||||
|
/// Returned if we can guess that the server, by which I mean ejabberd, rejected
|
||||||
|
/// the publish due to not liking that we set "max_items" to "max".
|
||||||
|
/// NOTE: This workaround is required due to https://github.com/processone/ejabberd/issues/3044
|
||||||
|
// TODO(Unknown): Remove once ejabberd fixes it
|
||||||
|
class EjabberdMaxItemsError extends PubSubError {}
|
25
moxxmpp/lib/src/xeps/xep_0060/helpers.dart
Normal file
25
moxxmpp/lib/src/xeps/xep_0060/helpers.dart
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0060/errors.dart';
|
||||||
|
|
||||||
|
PubSubError getPubSubError(XMLNode stanza) {
|
||||||
|
final error = stanza.firstTag('error');
|
||||||
|
if (error != null) {
|
||||||
|
final conflict = error.firstTag('conflict');
|
||||||
|
final preconditions = error.firstTag('precondition-not-met');
|
||||||
|
if (conflict != null && preconditions != null) {
|
||||||
|
return PreconditionsNotMetError();
|
||||||
|
}
|
||||||
|
|
||||||
|
final badRequest = error.firstTag('bad-request', xmlns: fullStanzaXmlns);
|
||||||
|
final text = error.firstTag('text', xmlns: fullStanzaXmlns);
|
||||||
|
if (error.attributes['type'] == 'modify' &&
|
||||||
|
badRequest != null &&
|
||||||
|
text != null &&
|
||||||
|
(text.text ?? '').contains('max_items')) {
|
||||||
|
return EjabberdMaxItemsError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return UnknownPubSubError();
|
||||||
|
}
|
545
moxxmpp/lib/src/xeps/xep_0060/xep_0060.dart
Normal file
545
moxxmpp/lib/src/xeps/xep_0060/xep_0060.dart
Normal file
@ -0,0 +1,545 @@
|
|||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/types/resultv2.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0004.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/errors.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/types.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0060/errors.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0060/helpers.dart';
|
||||||
|
|
||||||
|
class PubSubPublishOptions {
|
||||||
|
|
||||||
|
const PubSubPublishOptions({
|
||||||
|
this.accessModel,
|
||||||
|
this.maxItems,
|
||||||
|
});
|
||||||
|
final String? accessModel;
|
||||||
|
final String? maxItems;
|
||||||
|
|
||||||
|
XMLNode toXml() {
|
||||||
|
return DataForm(
|
||||||
|
type: 'submit',
|
||||||
|
instructions: [],
|
||||||
|
reported: [],
|
||||||
|
items: [],
|
||||||
|
fields: [
|
||||||
|
const DataFormField(
|
||||||
|
options: [],
|
||||||
|
isRequired: false,
|
||||||
|
values: [ pubsubPublishOptionsXmlns ],
|
||||||
|
varAttr: 'FORM_TYPE',
|
||||||
|
type: 'hidden',
|
||||||
|
),
|
||||||
|
...accessModel != null ? [
|
||||||
|
DataFormField(
|
||||||
|
options: [],
|
||||||
|
isRequired: false,
|
||||||
|
values: [ accessModel! ],
|
||||||
|
varAttr: 'pubsub#access_model',
|
||||||
|
)
|
||||||
|
] : [],
|
||||||
|
...maxItems != null ? [
|
||||||
|
DataFormField(
|
||||||
|
options: [],
|
||||||
|
isRequired: false,
|
||||||
|
values: [maxItems! ],
|
||||||
|
varAttr: 'pubsub#max_items',
|
||||||
|
),
|
||||||
|
] : [],
|
||||||
|
],
|
||||||
|
).toXml();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PubSubItem {
|
||||||
|
|
||||||
|
const PubSubItem({ required this.id, required this.node, required this.payload });
|
||||||
|
final String id;
|
||||||
|
final String node;
|
||||||
|
final XMLNode payload;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$id: ${payload.toXml()}';
|
||||||
|
}
|
||||||
|
|
||||||
|
class PubSubManager extends XmppManagerBase {
|
||||||
|
@override
|
||||||
|
String getId() => pubsubManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'PubsubManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
tagName: 'event',
|
||||||
|
tagXmlns: pubsubEventXmlns,
|
||||||
|
callback: _onPubsubMessage,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onPubsubMessage(Stanza message, StanzaHandlerData state) async {
|
||||||
|
logger.finest('Received PubSub event');
|
||||||
|
final event = message.firstTag('event', xmlns: pubsubEventXmlns)!;
|
||||||
|
final items = event.firstTag('items')!;
|
||||||
|
final item = items.firstTag('item')!;
|
||||||
|
|
||||||
|
getAttributes().sendEvent(PubSubNotificationEvent(
|
||||||
|
item: PubSubItem(
|
||||||
|
id: item.attributes['id']! as String,
|
||||||
|
node: items.attributes['node']! as String,
|
||||||
|
payload: item.children[0],
|
||||||
|
),
|
||||||
|
from: message.attributes['from']! as String,
|
||||||
|
),);
|
||||||
|
|
||||||
|
return state.copyWith(done: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _getNodeItemCount(String jid, String node) async {
|
||||||
|
final dm = getAttributes().getManagerById<DiscoManager>(discoManager)!;
|
||||||
|
final response = await dm.discoItemsQuery(jid, node: node);
|
||||||
|
var count = 0;
|
||||||
|
if (response.isType<DiscoError>()) {
|
||||||
|
logger.warning('_getNodeItemCount: disco#items query failed. Assuming no items.');
|
||||||
|
} else {
|
||||||
|
count = response.get<List<DiscoItem>>().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PubSubPublishOptions> _preprocessPublishOptions(String jid, String node, PubSubPublishOptions options) async {
|
||||||
|
if (options.maxItems != null) {
|
||||||
|
final dm = getAttributes().getManagerById<DiscoManager>(discoManager)!;
|
||||||
|
final result = await dm.discoInfoQuery(jid);
|
||||||
|
if (result.isType<DiscoError>()) {
|
||||||
|
if (options.maxItems == 'max') {
|
||||||
|
logger.severe('disco#info query failed and options.maxItems is set to "max".');
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final nodeMultiItemsSupported = result.isType<DiscoInfo>() && result.get<DiscoInfo>().features.contains(pubsubNodeConfigMultiItems);
|
||||||
|
final nodeMaxSupported = result.isType<DiscoInfo>() && result.get<DiscoInfo>().features.contains(pubsubNodeConfigMax);
|
||||||
|
if (options.maxItems != null && !nodeMultiItemsSupported) {
|
||||||
|
// TODO(PapaTutuWawa): Here, we need to admit defeat
|
||||||
|
logger.finest('PubSub host does not support multi-items!');
|
||||||
|
|
||||||
|
return PubSubPublishOptions(
|
||||||
|
accessModel: options.accessModel,
|
||||||
|
);
|
||||||
|
} else if (options.maxItems == 'max' && !nodeMaxSupported) {
|
||||||
|
logger.finest('PubSub host does not support node-config-max. Working around it');
|
||||||
|
final count = await _getNodeItemCount(jid, node) + 1;
|
||||||
|
|
||||||
|
return PubSubPublishOptions(
|
||||||
|
accessModel: options.accessModel,
|
||||||
|
maxItems: '$count',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<PubSubError, bool>> subscribe(String jid, String node) async {
|
||||||
|
final attrs = getAttributes();
|
||||||
|
final result = await attrs.sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
type: 'set',
|
||||||
|
to: jid,
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'pubsub',
|
||||||
|
xmlns: pubsubXmlns,
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'subscribe',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'node': node,
|
||||||
|
'jid': attrs.getFullJID().toBare().toString(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.attributes['type'] != 'result') return Result(UnknownPubSubError());
|
||||||
|
|
||||||
|
final pubsub = result.firstTag('pubsub', xmlns: pubsubXmlns);
|
||||||
|
if (pubsub == null) return Result(UnknownPubSubError());
|
||||||
|
|
||||||
|
final subscription = pubsub.firstTag('subscription');
|
||||||
|
if (subscription == null) return Result(UnknownPubSubError());
|
||||||
|
|
||||||
|
return Result(subscription.attributes['subscription'] == 'subscribed');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<PubSubError, bool>> unsubscribe(String jid, String node) async {
|
||||||
|
final attrs = getAttributes();
|
||||||
|
final result = await attrs.sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
type: 'set',
|
||||||
|
to: jid,
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'pubsub',
|
||||||
|
xmlns: pubsubXmlns,
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'unsubscribe',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'node': node,
|
||||||
|
'jid': attrs.getFullJID().toBare().toString(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.attributes['type'] != 'result') return Result(UnknownPubSubError());
|
||||||
|
|
||||||
|
final pubsub = result.firstTag('pubsub', xmlns: pubsubXmlns);
|
||||||
|
if (pubsub == null) return Result(UnknownPubSubError());
|
||||||
|
|
||||||
|
final subscription = pubsub.firstTag('subscription');
|
||||||
|
if (subscription == null) return Result(UnknownPubSubError());
|
||||||
|
|
||||||
|
return Result(subscription.attributes['subscription'] == 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish [payload] to the PubSub node [node] on JID [jid]. Returns true if it
|
||||||
|
/// was successful. False otherwise.
|
||||||
|
Future<Result<PubSubError, bool>> publish(
|
||||||
|
String jid,
|
||||||
|
String node,
|
||||||
|
XMLNode payload, {
|
||||||
|
String? id,
|
||||||
|
PubSubPublishOptions? options,
|
||||||
|
}
|
||||||
|
) async {
|
||||||
|
return _publish(
|
||||||
|
jid,
|
||||||
|
node,
|
||||||
|
payload,
|
||||||
|
id: id,
|
||||||
|
options: options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<PubSubError, bool>> _publish(
|
||||||
|
String jid,
|
||||||
|
String node,
|
||||||
|
XMLNode payload, {
|
||||||
|
String? id,
|
||||||
|
PubSubPublishOptions? options,
|
||||||
|
// Should, if publishing fails, try to reconfigure and publish again?
|
||||||
|
bool tryConfigureAndPublish = true,
|
||||||
|
}
|
||||||
|
) async {
|
||||||
|
PubSubPublishOptions? pubOptions;
|
||||||
|
if (options != null) {
|
||||||
|
pubOptions = await _preprocessPublishOptions(jid, node, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await getAttributes().sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
type: 'set',
|
||||||
|
to: jid,
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'pubsub',
|
||||||
|
xmlns: pubsubXmlns,
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'publish',
|
||||||
|
attributes: <String, String>{ 'node': node },
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'item',
|
||||||
|
attributes: id != null ? <String, String>{ 'id': id } : <String, String>{},
|
||||||
|
children: [ payload ],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
...options != null ? [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'publish-options',
|
||||||
|
children: [options.toXml()],
|
||||||
|
),
|
||||||
|
] : [],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result.attributes['type'] != 'result') {
|
||||||
|
final error = getPubSubError(result);
|
||||||
|
|
||||||
|
// If preconditions are not met, configure the node
|
||||||
|
if (error is PreconditionsNotMetError && tryConfigureAndPublish) {
|
||||||
|
final configureResult = await configure(jid, node, pubOptions!);
|
||||||
|
if (configureResult.isType<PubSubError>()) {
|
||||||
|
return Result(configureResult.get<PubSubError>());
|
||||||
|
}
|
||||||
|
|
||||||
|
final publishResult = await _publish(
|
||||||
|
jid,
|
||||||
|
node,
|
||||||
|
payload,
|
||||||
|
id: id,
|
||||||
|
options: options,
|
||||||
|
tryConfigureAndPublish: false,
|
||||||
|
);
|
||||||
|
if (publishResult.isType<PubSubError>()) return publishResult;
|
||||||
|
} else if (error is EjabberdMaxItemsError && tryConfigureAndPublish && options != null) {
|
||||||
|
// TODO(Unknown): Remove once ejabberd fixes the bug. See errors.dart for more info.
|
||||||
|
logger.warning('Publish failed due to the server rejecting the usage of "max" for "max_items" in publish options. Configuring...');
|
||||||
|
final count = await _getNodeItemCount(jid, node) + 1;
|
||||||
|
return publish(
|
||||||
|
jid,
|
||||||
|
node,
|
||||||
|
payload,
|
||||||
|
id: id,
|
||||||
|
options: PubSubPublishOptions(
|
||||||
|
accessModel: options.accessModel,
|
||||||
|
maxItems: '$count',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Result(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final pubsubElement = result.firstTag('pubsub', xmlns: pubsubXmlns);
|
||||||
|
if (pubsubElement == null) return Result(MalformedResponseError());
|
||||||
|
|
||||||
|
final publishElement = pubsubElement.firstTag('publish');
|
||||||
|
if (publishElement == null) return Result(MalformedResponseError());
|
||||||
|
|
||||||
|
final item = publishElement.firstTag('item');
|
||||||
|
if (item == null) return Result(MalformedResponseError());
|
||||||
|
|
||||||
|
if (id != null) return Result(item.attributes['id'] == id);
|
||||||
|
|
||||||
|
return const Result(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<PubSubError, List<PubSubItem>>> getItems(String jid, String node) async {
|
||||||
|
final result = await getAttributes().sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
type: 'get',
|
||||||
|
to: jid,
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'pubsub',
|
||||||
|
xmlns: pubsubXmlns,
|
||||||
|
children: [
|
||||||
|
XMLNode(tag: 'items', attributes: <String, String>{ 'node': node }),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.attributes['type'] != 'result') return Result(getPubSubError(result));
|
||||||
|
|
||||||
|
final pubsub = result.firstTag('pubsub', xmlns: pubsubXmlns);
|
||||||
|
if (pubsub == null) return Result(getPubSubError(result));
|
||||||
|
|
||||||
|
final items = pubsub
|
||||||
|
.firstTag('items')!
|
||||||
|
.children.map((item) {
|
||||||
|
return PubSubItem(
|
||||||
|
id: item.attributes['id']! as String,
|
||||||
|
payload: item.children[0],
|
||||||
|
node: node,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return Result(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<PubSubError, PubSubItem>> getItem(String jid, String node, String id) async {
|
||||||
|
final result = await getAttributes().sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
type: 'get',
|
||||||
|
to: jid,
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'pubsub',
|
||||||
|
xmlns: pubsubXmlns,
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'items',
|
||||||
|
attributes: <String, String>{ 'node': node },
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'item',
|
||||||
|
attributes: <String, String>{ 'id': id },
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.attributes['type'] != 'result') return Result(getPubSubError(result));
|
||||||
|
|
||||||
|
final pubsub = result.firstTag('pubsub', xmlns: pubsubXmlns);
|
||||||
|
if (pubsub == null) return Result(getPubSubError(result));
|
||||||
|
|
||||||
|
final itemElement = pubsub.firstTag('items')?.firstTag('item');
|
||||||
|
if (itemElement == null) return Result(NoItemReturnedError());
|
||||||
|
|
||||||
|
final item = PubSubItem(
|
||||||
|
id: itemElement.attributes['id']! as String,
|
||||||
|
payload: itemElement.children[0],
|
||||||
|
node: node,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<PubSubError, bool>> configure(String jid, String node, PubSubPublishOptions options) async {
|
||||||
|
final attrs = getAttributes();
|
||||||
|
|
||||||
|
// Request the form
|
||||||
|
final form = await attrs.sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
type: 'get',
|
||||||
|
to: jid,
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'pubsub',
|
||||||
|
xmlns: pubsubOwnerXmlns,
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'configure',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'node': node,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (form.attributes['type'] != 'result') return Result(getPubSubError(form));
|
||||||
|
|
||||||
|
final submit = await attrs.sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
type: 'set',
|
||||||
|
to: jid,
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'pubsub',
|
||||||
|
xmlns: pubsubOwnerXmlns,
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'configure',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'node': node,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
options.toXml(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (submit.attributes['type'] != 'result') return Result(getPubSubError(form));
|
||||||
|
|
||||||
|
return const Result(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<PubSubError, bool>> delete(JID host, String node) async {
|
||||||
|
final request = await getAttributes().sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
type: 'set',
|
||||||
|
to: host.toString(),
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'pubsub',
|
||||||
|
xmlns: pubsubOwnerXmlns,
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'delete',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'node': node,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
) as Stanza;
|
||||||
|
|
||||||
|
if (request.type != 'result') {
|
||||||
|
// TODO(Unknown): Be more specific
|
||||||
|
return Result(UnknownPubSubError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return const Result(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result<PubSubError, bool>> retract(JID host, String node, String itemId) async {
|
||||||
|
final request = await getAttributes().sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
type: 'set',
|
||||||
|
to: host.toString(),
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'pubsub',
|
||||||
|
xmlns: pubsubXmlns,
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'retract',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'node': node,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'item',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'id': itemId,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
) as Stanza;
|
||||||
|
|
||||||
|
if (request.type != 'result') {
|
||||||
|
// TODO(Unknown): Be more specific
|
||||||
|
return Result(UnknownPubSubError());
|
||||||
|
}
|
||||||
|
|
||||||
|
return const Result(true);
|
||||||
|
}
|
||||||
|
}
|
71
moxxmpp/lib/src/xeps/xep_0066.dart
Normal file
71
moxxmpp/lib/src/xeps/xep_0066.dart
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
/// A data class representing the jabber:x:oob tag.
|
||||||
|
class OOBData {
|
||||||
|
|
||||||
|
const OOBData({ this.url, this.desc });
|
||||||
|
final String? url;
|
||||||
|
final String? desc;
|
||||||
|
}
|
||||||
|
|
||||||
|
XMLNode constructOOBNode(OOBData data) {
|
||||||
|
final children = List<XMLNode>.empty(growable: true);
|
||||||
|
|
||||||
|
if (data.url != null) {
|
||||||
|
children.add(XMLNode(tag: 'url', text: data.url));
|
||||||
|
}
|
||||||
|
if (data.desc != null) {
|
||||||
|
children.add(XMLNode(tag: 'desc', text: data.desc));
|
||||||
|
}
|
||||||
|
|
||||||
|
return XMLNode.xmlns(
|
||||||
|
tag: 'x',
|
||||||
|
xmlns: oobDataXmlns,
|
||||||
|
children: children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class OOBManager extends XmppManagerBase {
|
||||||
|
@override
|
||||||
|
String getName() => 'OOBName';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => oobManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> getDiscoFeatures() => [ oobDataXmlns ];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
tagName: 'x',
|
||||||
|
tagXmlns: oobDataXmlns,
|
||||||
|
callback: _onMessage,
|
||||||
|
// Before the message manager
|
||||||
|
priority: -99,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onMessage(Stanza message, StanzaHandlerData state) async {
|
||||||
|
final x = message.firstTag('x', xmlns: oobDataXmlns)!;
|
||||||
|
final url = x.firstTag('url');
|
||||||
|
final desc = x.firstTag('desc');
|
||||||
|
|
||||||
|
return state.copyWith(
|
||||||
|
oob: OOBData(
|
||||||
|
url: url?.innerText(),
|
||||||
|
desc: desc?.innerText(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
173
moxxmpp/lib/src/xeps/xep_0084.dart
Normal file
173
moxxmpp/lib/src/xeps/xep_0084.dart
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/errors.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/types.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0060/errors.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0060/xep_0060.dart';
|
||||||
|
|
||||||
|
class UserAvatar {
|
||||||
|
|
||||||
|
const UserAvatar({ required this.base64, required this.hash });
|
||||||
|
final String base64;
|
||||||
|
final String hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserAvatarMetadata {
|
||||||
|
|
||||||
|
const UserAvatarMetadata(
|
||||||
|
this.id,
|
||||||
|
this.length,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.mime,
|
||||||
|
);
|
||||||
|
/// The amount of bytes in the file
|
||||||
|
final int length;
|
||||||
|
/// The identifier of the avatar
|
||||||
|
final String id;
|
||||||
|
/// Image proportions
|
||||||
|
final int width;
|
||||||
|
final int height;
|
||||||
|
/// The MIME type of the avatar
|
||||||
|
final String mime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NOTE: This class requires a PubSubManager
|
||||||
|
class UserAvatarManager extends XmppManagerBase {
|
||||||
|
@override
|
||||||
|
String getId() => userAvatarManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'UserAvatarManager';
|
||||||
|
|
||||||
|
PubSubManager _getPubSubManager() => getAttributes().getManagerById(pubsubManager)! as PubSubManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onXmppEvent(XmppEvent event) async {
|
||||||
|
if (event is PubSubNotificationEvent) {
|
||||||
|
getAttributes().sendEvent(
|
||||||
|
AvatarUpdatedEvent(
|
||||||
|
jid: event.from,
|
||||||
|
base64: event.item.payload.innerText(),
|
||||||
|
hash: event.item.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(PapaTutuWawa): Check for PEP support
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
/// Requests the avatar from [jid]. Returns the avatar data if the request was
|
||||||
|
/// successful. Null otherwise
|
||||||
|
// TODO(Unknown): Migrate to Resultsv2
|
||||||
|
Future<UserAvatar?> getUserAvatar(String jid) async {
|
||||||
|
final pubsub = _getPubSubManager();
|
||||||
|
final resultsRaw = await pubsub.getItems(jid, userAvatarDataXmlns);
|
||||||
|
if (resultsRaw.isType<PubSubError>()) return null;
|
||||||
|
|
||||||
|
final results = resultsRaw.get<List<PubSubItem>>();
|
||||||
|
if (results.isEmpty) return null;
|
||||||
|
|
||||||
|
final item = results[0];
|
||||||
|
return UserAvatar(
|
||||||
|
base64: item.payload.innerText(),
|
||||||
|
hash: item.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish the avatar data, [base64], on the pubsub node using [hash] as
|
||||||
|
/// the item id. [hash] must be the SHA-1 hash of the image data, while
|
||||||
|
/// [base64] must be the base64-encoded version of the image data.
|
||||||
|
// TODO(Unknown): Migrate to Resultsv2
|
||||||
|
Future<bool> publishUserAvatar(String base64, String hash, bool public) async {
|
||||||
|
final pubsub = _getPubSubManager();
|
||||||
|
final result = await pubsub.publish(
|
||||||
|
getAttributes().getFullJID().toBare().toString(),
|
||||||
|
userAvatarDataXmlns,
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'data',
|
||||||
|
xmlns: userAvatarDataXmlns,
|
||||||
|
text: base64,
|
||||||
|
),
|
||||||
|
id: hash,
|
||||||
|
options: PubSubPublishOptions(
|
||||||
|
accessModel: public ? 'open' : 'roster',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return !result.isType<PubSubError>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish avatar metadata [metadata] to the User Avatar's metadata node. If [public]
|
||||||
|
/// is true, then the node will be set to an 'open' access model. If [public] is false,
|
||||||
|
/// then the node will be set to an 'roster' access model.
|
||||||
|
// TODO(Unknown): Migrate to Resultsv2
|
||||||
|
Future<bool> publishUserAvatarMetadata(UserAvatarMetadata metadata, bool public) async {
|
||||||
|
final pubsub = _getPubSubManager();
|
||||||
|
final result = await pubsub.publish(
|
||||||
|
getAttributes().getFullJID().toBare().toString(),
|
||||||
|
userAvatarMetadataXmlns,
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'metadata',
|
||||||
|
xmlns: userAvatarMetadataXmlns,
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'info',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'bytes': metadata.length.toString(),
|
||||||
|
'height': metadata.height.toString(),
|
||||||
|
'width': metadata.width.toString(),
|
||||||
|
'type': metadata.mime,
|
||||||
|
'id': metadata.id,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
id: metadata.id,
|
||||||
|
options: PubSubPublishOptions(
|
||||||
|
accessModel: public ? 'open' : 'roster',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.isType<PubSubError>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe the data and metadata node of [jid].
|
||||||
|
// TODO(Unknown): Migrate to Resultsv2
|
||||||
|
Future<bool> subscribe(String jid) async {
|
||||||
|
await _getPubSubManager().subscribe(jid, userAvatarDataXmlns);
|
||||||
|
await _getPubSubManager().subscribe(jid, userAvatarMetadataXmlns);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unsubscribe the data and metadata node of [jid].
|
||||||
|
// TODO(Unknown): Migrate to Resultsv2
|
||||||
|
Future<bool> unsubscribe(String jid) async {
|
||||||
|
await _getPubSubManager().unsubscribe(jid, userAvatarDataXmlns);
|
||||||
|
await _getPubSubManager().subscribe(jid, userAvatarMetadataXmlns);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the PubSub Id of an avatar after doing a disco#items query.
|
||||||
|
/// Note that this assumes that there is only one (1) item published on
|
||||||
|
/// the node.
|
||||||
|
// TODO(Unknown): Migrate to Resultsv2
|
||||||
|
Future<String?> getAvatarId(String jid) async {
|
||||||
|
final disco = getAttributes().getManagerById(discoManager)! as DiscoManager;
|
||||||
|
final response = await disco.discoItemsQuery(jid, node: userAvatarDataXmlns);
|
||||||
|
if (response.isType<DiscoError>()) return null;
|
||||||
|
|
||||||
|
final items = response.get<List<DiscoItem>>();
|
||||||
|
if (items.isEmpty) return null;
|
||||||
|
|
||||||
|
return items.first.name;
|
||||||
|
}
|
||||||
|
}
|
111
moxxmpp/lib/src/xeps/xep_0085.dart
Normal file
111
moxxmpp/lib/src/xeps/xep_0085.dart
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
enum ChatState {
|
||||||
|
active,
|
||||||
|
composing,
|
||||||
|
paused,
|
||||||
|
inactive,
|
||||||
|
gone
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatState chatStateFromString(String raw) {
|
||||||
|
switch(raw) {
|
||||||
|
case 'active': {
|
||||||
|
return ChatState.active;
|
||||||
|
}
|
||||||
|
case 'composing': {
|
||||||
|
return ChatState.composing;
|
||||||
|
}
|
||||||
|
case 'paused': {
|
||||||
|
return ChatState.paused;
|
||||||
|
}
|
||||||
|
case 'inactive': {
|
||||||
|
return ChatState.inactive;
|
||||||
|
}
|
||||||
|
case 'gone': {
|
||||||
|
return ChatState.gone;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return ChatState.gone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String chatStateToString(ChatState state) => state.toString().split('.').last;
|
||||||
|
|
||||||
|
class ChatStateManager extends XmppManagerBase {
|
||||||
|
@override
|
||||||
|
List<String> getDiscoFeatures() => [ chatStateXmlns ];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'ChatStateManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => chatStateManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
tagXmlns: chatStateXmlns,
|
||||||
|
callback: _onChatStateReceived,
|
||||||
|
// Before the message handler
|
||||||
|
priority: -99,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onChatStateReceived(Stanza message, StanzaHandlerData state) async {
|
||||||
|
final element = state.stanza.firstTagByXmlns(chatStateXmlns)!;
|
||||||
|
ChatState? chatState;
|
||||||
|
|
||||||
|
switch (element.tag) {
|
||||||
|
case 'active': {
|
||||||
|
chatState = ChatState.active;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'composing': {
|
||||||
|
chatState = ChatState.composing;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'paused': {
|
||||||
|
chatState = ChatState.paused;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'inactive': {
|
||||||
|
chatState = ChatState.inactive;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'gone': {
|
||||||
|
chatState = ChatState.gone;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
logger.warning("Received invalid chat state '${element.tag}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.copyWith(chatState: chatState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a chat state notification to [to]. You can specify the type attribute
|
||||||
|
/// of the message with [messageType].
|
||||||
|
void sendChatState(ChatState state, String to, { String messageType = 'chat' }) {
|
||||||
|
final tagName = state.toString().split('.').last;
|
||||||
|
|
||||||
|
getAttributes().sendStanza(
|
||||||
|
Stanza.message(
|
||||||
|
to: to,
|
||||||
|
type: messageType,
|
||||||
|
children: [ XMLNode.xmlns(tag: tagName, xmlns: chatStateXmlns) ],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
59
moxxmpp/lib/src/xeps/xep_0115.dart
Normal file
59
moxxmpp/lib/src/xeps/xep_0115.dart
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:cryptography/cryptography.dart';
|
||||||
|
import 'package:moxxmpp/src/rfcs/rfc_4790.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/types.dart';
|
||||||
|
|
||||||
|
class CapabilityHashInfo {
|
||||||
|
|
||||||
|
const CapabilityHashInfo(this.ver, this.node, this.hash);
|
||||||
|
final String ver;
|
||||||
|
final String node;
|
||||||
|
final String hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the Entitiy Capability hash according to XEP-0115 based on the
|
||||||
|
/// disco information.
|
||||||
|
Future<String> calculateCapabilityHash(DiscoInfo info, HashAlgorithm algorithm) async {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
final identitiesSorted = info.identities
|
||||||
|
.map((Identity i) => '${i.category}/${i.type}/${i.lang ?? ""}/${i.name ?? ""}')
|
||||||
|
.toList();
|
||||||
|
// ignore: cascade_invocations
|
||||||
|
identitiesSorted.sort(ioctetSortComparator);
|
||||||
|
buffer.write('${identitiesSorted.join("<")}<');
|
||||||
|
|
||||||
|
final featuresSorted = List<String>.from(info.features)
|
||||||
|
..sort(ioctetSortComparator);
|
||||||
|
buffer.write('${featuresSorted.join("<")}<');
|
||||||
|
|
||||||
|
if (info.extendedInfo.isNotEmpty) {
|
||||||
|
final sortedExt = info.extendedInfo
|
||||||
|
..sort((a, b) => ioctetSortComparator(
|
||||||
|
a.getFieldByVar('FORM_TYPE')!.values.first,
|
||||||
|
b.getFieldByVar('FORM_TYPE')!.values.first,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final ext in sortedExt) {
|
||||||
|
buffer.write('${ext.getFieldByVar("FORM_TYPE")!.values.first}<');
|
||||||
|
|
||||||
|
final sortedFields = ext.fields..sort((a, b) => ioctetSortComparator(
|
||||||
|
a.varAttr!,
|
||||||
|
b.varAttr!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final field in sortedFields) {
|
||||||
|
if (field.varAttr == 'FORM_TYPE') continue;
|
||||||
|
|
||||||
|
buffer.write('${field.varAttr!}<');
|
||||||
|
final sortedValues = field.values..sort(ioctetSortComparator);
|
||||||
|
for (final value in sortedValues) {
|
||||||
|
buffer.write('$value<');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.encode((await algorithm.hash(utf8.encode(buffer.toString()))).bytes);
|
||||||
|
}
|
81
moxxmpp/lib/src/xeps/xep_0184.dart
Normal file
81
moxxmpp/lib/src/xeps/xep_0184.dart
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
XMLNode makeMessageDeliveryRequest() {
|
||||||
|
return XMLNode.xmlns(
|
||||||
|
tag: 'request',
|
||||||
|
xmlns: deliveryXmlns,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
XMLNode makeMessageDeliveryResponse(String id) {
|
||||||
|
return XMLNode.xmlns(
|
||||||
|
tag: 'received',
|
||||||
|
xmlns: deliveryXmlns,
|
||||||
|
attributes: { 'id': id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageDeliveryReceiptManager extends XmppManagerBase {
|
||||||
|
@override
|
||||||
|
List<String> getDiscoFeatures() => [ deliveryXmlns ];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'MessageDeliveryReceiptManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => messageDeliveryReceiptManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
tagName: 'received',
|
||||||
|
tagXmlns: deliveryXmlns,
|
||||||
|
callback: _onDeliveryReceiptReceived,
|
||||||
|
// Before the message handler
|
||||||
|
priority: -99,
|
||||||
|
),
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
tagName: 'request',
|
||||||
|
tagXmlns: deliveryXmlns,
|
||||||
|
callback: _onDeliveryRequestReceived,
|
||||||
|
// Before the message handler
|
||||||
|
priority: -99,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onDeliveryRequestReceived(Stanza message, StanzaHandlerData state) async {
|
||||||
|
return state.copyWith(deliveryReceiptRequested: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onDeliveryReceiptReceived(Stanza message, StanzaHandlerData state) async {
|
||||||
|
final received = message.firstTag('received', xmlns: deliveryXmlns)!;
|
||||||
|
for (final item in message.children) {
|
||||||
|
if (!['origin-id', 'stanza-id', 'delay', 'store', 'received'].contains(item.tag)) {
|
||||||
|
logger.info("Won't handle stanza as delivery receipt because we found an '${item.tag}' element");
|
||||||
|
|
||||||
|
return state.copyWith(done: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAttributes().sendEvent(
|
||||||
|
DeliveryReceiptReceivedEvent(
|
||||||
|
from: JID.fromString(message.attributes['from']! as String),
|
||||||
|
id: received.attributes['id']! as String,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return state.copyWith(done: true);
|
||||||
|
}
|
||||||
|
}
|
170
moxxmpp/lib/src/xeps/xep_0191.dart
Normal file
170
moxxmpp/lib/src/xeps/xep_0191.dart
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart';
|
||||||
|
|
||||||
|
class BlockingManager extends XmppManagerBase {
|
||||||
|
BlockingManager() : _supported = false, _gotSupported = false, super();
|
||||||
|
|
||||||
|
bool _supported;
|
||||||
|
bool _gotSupported;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => blockingManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'BlockingManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'iq',
|
||||||
|
tagName: 'unblock',
|
||||||
|
tagXmlns: blockingXmlns,
|
||||||
|
callback: _unblockPush,
|
||||||
|
),
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'iq',
|
||||||
|
tagName: 'block',
|
||||||
|
tagXmlns: blockingXmlns,
|
||||||
|
callback: _blockPush,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async {
|
||||||
|
if (_gotSupported) return _supported;
|
||||||
|
|
||||||
|
// Query the server
|
||||||
|
final disco = getAttributes().getManagerById<DiscoManager>(discoManager)!;
|
||||||
|
_supported = await disco.supportsFeature(
|
||||||
|
getAttributes().getConnectionSettings().jid.toBare(),
|
||||||
|
blockingXmlns,
|
||||||
|
);
|
||||||
|
_gotSupported = true;
|
||||||
|
return _supported;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onXmppEvent(XmppEvent event) async {
|
||||||
|
if (event is StreamResumeFailedEvent) {
|
||||||
|
_gotSupported = false;
|
||||||
|
_supported = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _blockPush(Stanza iq, StanzaHandlerData state) async {
|
||||||
|
final block = iq.firstTag('block', xmlns: blockingXmlns)!;
|
||||||
|
|
||||||
|
getAttributes().sendEvent(
|
||||||
|
BlocklistBlockPushEvent(
|
||||||
|
items: block.findTags('item').map((i) => i.attributes['jid']! as String).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return state.copyWith(done: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _unblockPush(Stanza iq, StanzaHandlerData state) async {
|
||||||
|
final unblock = iq.firstTag('unblock', xmlns: blockingXmlns)!;
|
||||||
|
final items = unblock.findTags('item');
|
||||||
|
|
||||||
|
if (items.isNotEmpty) {
|
||||||
|
getAttributes().sendEvent(
|
||||||
|
BlocklistUnblockPushEvent(
|
||||||
|
items: items.map((i) => i.attributes['jid']! as String).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
getAttributes().sendEvent(
|
||||||
|
BlocklistUnblockAllPushEvent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.copyWith(done: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> block(List<String> items) async {
|
||||||
|
final result = await getAttributes().sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
type: 'set',
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'block',
|
||||||
|
xmlns: blockingXmlns,
|
||||||
|
children: items
|
||||||
|
.map((item) {
|
||||||
|
return XMLNode(
|
||||||
|
tag: 'item',
|
||||||
|
attributes: <String, String>{ 'jid': item },
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.attributes['type'] == 'result';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> unblockAll() async {
|
||||||
|
final result = await getAttributes().sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
type: 'set',
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'unblock',
|
||||||
|
xmlns: blockingXmlns,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.attributes['type'] == 'result';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> unblock(List<String> items) async {
|
||||||
|
assert(items.isNotEmpty, 'The list of items to unblock must be non-empty');
|
||||||
|
|
||||||
|
final result = await getAttributes().sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
type: 'set',
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'unblock',
|
||||||
|
xmlns: blockingXmlns,
|
||||||
|
children: items.map((item) => XMLNode(
|
||||||
|
tag: 'item',
|
||||||
|
attributes: <String, String>{ 'jid': item },
|
||||||
|
),).toList(),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.attributes['type'] == 'result';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> getBlocklist() async {
|
||||||
|
final result = await getAttributes().sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
type: 'get',
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'blocklist',
|
||||||
|
xmlns: blockingXmlns,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final blocklist = result.firstTag('blocklist', xmlns: blockingXmlns)!;
|
||||||
|
return blocklist.findTags('item').map((item) => item.attributes['jid']! as String).toList();
|
||||||
|
}
|
||||||
|
}
|
156
moxxmpp/lib/src/xeps/xep_0198/negotiator.dart
Normal file
156
moxxmpp/lib/src/xeps/xep_0198/negotiator.dart
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/negotiator.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0198/nonzas.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0198/state.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0198/xep_0198.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0352.dart';
|
||||||
|
|
||||||
|
enum _StreamManagementNegotiatorState {
|
||||||
|
// We have not done anything yet
|
||||||
|
ready,
|
||||||
|
// The SM resume has been requested
|
||||||
|
resumeRequested,
|
||||||
|
// The SM enablement has been requested
|
||||||
|
enableRequested,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NOTE: The stream management negotiator requires that loadState has been called on the
|
||||||
|
/// StreamManagementManager at least once before connecting, if stream resumption
|
||||||
|
/// is wanted.
|
||||||
|
class StreamManagementNegotiator extends XmppFeatureNegotiatorBase {
|
||||||
|
|
||||||
|
StreamManagementNegotiator()
|
||||||
|
: _state = _StreamManagementNegotiatorState.ready,
|
||||||
|
_supported = false,
|
||||||
|
_resumeFailed = false,
|
||||||
|
_isResumed = false,
|
||||||
|
_log = Logger('StreamManagementNegotiator'),
|
||||||
|
super(10, false, smXmlns, streamManagementNegotiator);
|
||||||
|
_StreamManagementNegotiatorState _state;
|
||||||
|
bool _resumeFailed;
|
||||||
|
bool _isResumed;
|
||||||
|
|
||||||
|
final Logger _log;
|
||||||
|
|
||||||
|
/// True if Stream Management is supported on this stream.
|
||||||
|
bool _supported;
|
||||||
|
bool get isSupported => _supported;
|
||||||
|
|
||||||
|
/// True if the current stream is resumed. False if not.
|
||||||
|
bool get isResumed => _isResumed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool matchesFeature(List<XMLNode> features) {
|
||||||
|
final sm = attributes.getManagerById<StreamManagementManager>(smManager)!;
|
||||||
|
|
||||||
|
if (sm.state.streamResumptionId != null && !_resumeFailed) {
|
||||||
|
// We could do Stream resumption
|
||||||
|
return super.matchesFeature(features) && attributes.isAuthenticated();
|
||||||
|
} else {
|
||||||
|
// We cannot do a stream resumption
|
||||||
|
final br = attributes.getNegotiatorById(resourceBindingNegotiator);
|
||||||
|
return super.matchesFeature(features) && br?.state == NegotiatorState.done && attributes.isAuthenticated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> negotiate(XMLNode nonza) async {
|
||||||
|
// negotiate is only called when we matched the stream feature, so we know
|
||||||
|
// that the server advertises it.
|
||||||
|
_supported = true;
|
||||||
|
|
||||||
|
switch (_state) {
|
||||||
|
case _StreamManagementNegotiatorState.ready:
|
||||||
|
final sm = attributes.getManagerById<StreamManagementManager>(smManager)!;
|
||||||
|
final srid = sm.state.streamResumptionId;
|
||||||
|
final h = sm.state.s2c;
|
||||||
|
|
||||||
|
// Attempt stream resumption first
|
||||||
|
if (srid != null) {
|
||||||
|
_log.finest('Found stream resumption Id. Attempting to perform stream resumption');
|
||||||
|
_state = _StreamManagementNegotiatorState.resumeRequested;
|
||||||
|
attributes.sendNonza(StreamManagementResumeNonza(srid, h));
|
||||||
|
} else {
|
||||||
|
_log.finest('Attempting to enable stream management');
|
||||||
|
_state = _StreamManagementNegotiatorState.enableRequested;
|
||||||
|
attributes.sendNonza(StreamManagementEnableNonza());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case _StreamManagementNegotiatorState.resumeRequested:
|
||||||
|
if (nonza.tag == 'resumed') {
|
||||||
|
_log.finest('Stream Management resumption successful');
|
||||||
|
|
||||||
|
assert(attributes.getFullJID().resource != '', 'Resume only works when we already have a resource bound and know about it');
|
||||||
|
|
||||||
|
final csi = attributes.getManagerById(csiManager) as CSIManager?;
|
||||||
|
if (csi != null) {
|
||||||
|
csi.restoreCSIState();
|
||||||
|
}
|
||||||
|
|
||||||
|
final h = int.parse(nonza.attributes['h']! as String);
|
||||||
|
await attributes.sendEvent(StreamResumedEvent(h: h));
|
||||||
|
|
||||||
|
_resumeFailed = false;
|
||||||
|
_isResumed = true;
|
||||||
|
state = NegotiatorState.skipRest;
|
||||||
|
} else {
|
||||||
|
// We assume it is <failed />
|
||||||
|
_log.info('Stream resumption failed. Expected <resumed />, got ${nonza.tag}, Proceeding with new stream...');
|
||||||
|
await attributes.sendEvent(StreamResumeFailedEvent());
|
||||||
|
final sm = attributes.getManagerById<StreamManagementManager>(smManager)!;
|
||||||
|
|
||||||
|
// We have to do this because we otherwise get a stanza stuck in the queue,
|
||||||
|
// thus spamming the server on every <a /> nonza we receive.
|
||||||
|
// ignore: cascade_invocations
|
||||||
|
await sm.setState(StreamManagementState(0, 0));
|
||||||
|
await sm.commitState();
|
||||||
|
|
||||||
|
_resumeFailed = true;
|
||||||
|
_isResumed = false;
|
||||||
|
_state = _StreamManagementNegotiatorState.ready;
|
||||||
|
state = NegotiatorState.retryLater;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case _StreamManagementNegotiatorState.enableRequested:
|
||||||
|
if (nonza.tag == 'enabled') {
|
||||||
|
_log.finest('Stream Management enabled');
|
||||||
|
|
||||||
|
final id = nonza.attributes['id'] as String?;
|
||||||
|
if (id != null && ['true', '1'].contains(nonza.attributes['resume'])) {
|
||||||
|
_log.info('Stream Resumption available');
|
||||||
|
}
|
||||||
|
|
||||||
|
await attributes.sendEvent(
|
||||||
|
StreamManagementEnabledEvent(
|
||||||
|
resource: attributes.getFullJID().resource,
|
||||||
|
id: id,
|
||||||
|
location: nonza.attributes['location'] as String?,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
state = NegotiatorState.done;
|
||||||
|
} else {
|
||||||
|
// We assume a <failed />
|
||||||
|
_log.warning('Stream Management enablement failed');
|
||||||
|
state = NegotiatorState.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void reset() {
|
||||||
|
_state = _StreamManagementNegotiatorState.ready;
|
||||||
|
_supported = false;
|
||||||
|
_resumeFailed = false;
|
||||||
|
_isResumed = false;
|
||||||
|
|
||||||
|
super.reset();
|
||||||
|
}
|
||||||
|
}
|
42
moxxmpp/lib/src/xeps/xep_0198/nonzas.dart
Normal file
42
moxxmpp/lib/src/xeps/xep_0198/nonzas.dart
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
class StreamManagementEnableNonza extends XMLNode {
|
||||||
|
StreamManagementEnableNonza() : super(
|
||||||
|
tag: 'enable',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'xmlns': smXmlns,
|
||||||
|
'resume': 'true'
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class StreamManagementResumeNonza extends XMLNode {
|
||||||
|
StreamManagementResumeNonza(String id, int h) : super(
|
||||||
|
tag: 'resume',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'xmlns': smXmlns,
|
||||||
|
'previd': id,
|
||||||
|
'h': h.toString()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class StreamManagementAckNonza extends XMLNode {
|
||||||
|
StreamManagementAckNonza(int h) : super(
|
||||||
|
tag: 'a',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'xmlns': smXmlns,
|
||||||
|
'h': h.toString()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class StreamManagementRequestNonza extends XMLNode {
|
||||||
|
StreamManagementRequestNonza() : super(
|
||||||
|
tag: 'r',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'xmlns': smXmlns,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
19
moxxmpp/lib/src/xeps/xep_0198/state.dart
Normal file
19
moxxmpp/lib/src/xeps/xep_0198/state.dart
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'state.freezed.dart';
|
||||||
|
part 'state.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class StreamManagementState with _$StreamManagementState {
|
||||||
|
factory StreamManagementState(
|
||||||
|
int c2s,
|
||||||
|
int s2c,
|
||||||
|
{
|
||||||
|
String? streamResumptionLocation,
|
||||||
|
String? streamResumptionId,
|
||||||
|
}
|
||||||
|
) = _StreamManagementState;
|
||||||
|
|
||||||
|
// JSON
|
||||||
|
factory StreamManagementState.fromJson(Map<String, dynamic> json) => _$StreamManagementStateFromJson(json);
|
||||||
|
}
|
217
moxxmpp/lib/src/xeps/xep_0198/state.freezed.dart
Normal file
217
moxxmpp/lib/src/xeps/xep_0198/state.freezed.dart
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
// coverage:ignore-file
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target
|
||||||
|
|
||||||
|
part of 'state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
final _privateConstructorUsedError = UnsupportedError(
|
||||||
|
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
|
||||||
|
|
||||||
|
StreamManagementState _$StreamManagementStateFromJson(
|
||||||
|
Map<String, dynamic> json) {
|
||||||
|
return _StreamManagementState.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$StreamManagementState {
|
||||||
|
int get c2s => throw _privateConstructorUsedError;
|
||||||
|
int get s2c => throw _privateConstructorUsedError;
|
||||||
|
String? get streamResumptionLocation => throw _privateConstructorUsedError;
|
||||||
|
String? get streamResumptionId => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
$StreamManagementStateCopyWith<StreamManagementState> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class $StreamManagementStateCopyWith<$Res> {
|
||||||
|
factory $StreamManagementStateCopyWith(StreamManagementState value,
|
||||||
|
$Res Function(StreamManagementState) then) =
|
||||||
|
_$StreamManagementStateCopyWithImpl<$Res>;
|
||||||
|
$Res call(
|
||||||
|
{int c2s,
|
||||||
|
int s2c,
|
||||||
|
String? streamResumptionLocation,
|
||||||
|
String? streamResumptionId});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class _$StreamManagementStateCopyWithImpl<$Res>
|
||||||
|
implements $StreamManagementStateCopyWith<$Res> {
|
||||||
|
_$StreamManagementStateCopyWithImpl(this._value, this._then);
|
||||||
|
|
||||||
|
final StreamManagementState _value;
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Res Function(StreamManagementState) _then;
|
||||||
|
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? c2s = freezed,
|
||||||
|
Object? s2c = freezed,
|
||||||
|
Object? streamResumptionLocation = freezed,
|
||||||
|
Object? streamResumptionId = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(_value.copyWith(
|
||||||
|
c2s: c2s == freezed
|
||||||
|
? _value.c2s
|
||||||
|
: c2s // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
s2c: s2c == freezed
|
||||||
|
? _value.s2c
|
||||||
|
: s2c // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
streamResumptionLocation: streamResumptionLocation == freezed
|
||||||
|
? _value.streamResumptionLocation
|
||||||
|
: streamResumptionLocation // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
streamResumptionId: streamResumptionId == freezed
|
||||||
|
? _value.streamResumptionId
|
||||||
|
: streamResumptionId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$_StreamManagementStateCopyWith<$Res>
|
||||||
|
implements $StreamManagementStateCopyWith<$Res> {
|
||||||
|
factory _$$_StreamManagementStateCopyWith(_$_StreamManagementState value,
|
||||||
|
$Res Function(_$_StreamManagementState) then) =
|
||||||
|
__$$_StreamManagementStateCopyWithImpl<$Res>;
|
||||||
|
@override
|
||||||
|
$Res call(
|
||||||
|
{int c2s,
|
||||||
|
int s2c,
|
||||||
|
String? streamResumptionLocation,
|
||||||
|
String? streamResumptionId});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$_StreamManagementStateCopyWithImpl<$Res>
|
||||||
|
extends _$StreamManagementStateCopyWithImpl<$Res>
|
||||||
|
implements _$$_StreamManagementStateCopyWith<$Res> {
|
||||||
|
__$$_StreamManagementStateCopyWithImpl(_$_StreamManagementState _value,
|
||||||
|
$Res Function(_$_StreamManagementState) _then)
|
||||||
|
: super(_value, (v) => _then(v as _$_StreamManagementState));
|
||||||
|
|
||||||
|
@override
|
||||||
|
_$_StreamManagementState get _value =>
|
||||||
|
super._value as _$_StreamManagementState;
|
||||||
|
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? c2s = freezed,
|
||||||
|
Object? s2c = freezed,
|
||||||
|
Object? streamResumptionLocation = freezed,
|
||||||
|
Object? streamResumptionId = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(_$_StreamManagementState(
|
||||||
|
c2s == freezed
|
||||||
|
? _value.c2s
|
||||||
|
: c2s // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
s2c == freezed
|
||||||
|
? _value.s2c
|
||||||
|
: s2c // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,
|
||||||
|
streamResumptionLocation: streamResumptionLocation == freezed
|
||||||
|
? _value.streamResumptionLocation
|
||||||
|
: streamResumptionLocation // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
streamResumptionId: streamResumptionId == freezed
|
||||||
|
? _value.streamResumptionId
|
||||||
|
: streamResumptionId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
class _$_StreamManagementState implements _StreamManagementState {
|
||||||
|
_$_StreamManagementState(this.c2s, this.s2c,
|
||||||
|
{this.streamResumptionLocation, this.streamResumptionId});
|
||||||
|
|
||||||
|
factory _$_StreamManagementState.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$$_StreamManagementStateFromJson(json);
|
||||||
|
|
||||||
|
@override
|
||||||
|
final int c2s;
|
||||||
|
@override
|
||||||
|
final int s2c;
|
||||||
|
@override
|
||||||
|
final String? streamResumptionLocation;
|
||||||
|
@override
|
||||||
|
final String? streamResumptionId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'StreamManagementState(c2s: $c2s, s2c: $s2c, streamResumptionLocation: $streamResumptionLocation, streamResumptionId: $streamResumptionId)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(dynamic other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$_StreamManagementState &&
|
||||||
|
const DeepCollectionEquality().equals(other.c2s, c2s) &&
|
||||||
|
const DeepCollectionEquality().equals(other.s2c, s2c) &&
|
||||||
|
const DeepCollectionEquality().equals(
|
||||||
|
other.streamResumptionLocation, streamResumptionLocation) &&
|
||||||
|
const DeepCollectionEquality()
|
||||||
|
.equals(other.streamResumptionId, streamResumptionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
runtimeType,
|
||||||
|
const DeepCollectionEquality().hash(c2s),
|
||||||
|
const DeepCollectionEquality().hash(s2c),
|
||||||
|
const DeepCollectionEquality().hash(streamResumptionLocation),
|
||||||
|
const DeepCollectionEquality().hash(streamResumptionId));
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
@override
|
||||||
|
_$$_StreamManagementStateCopyWith<_$_StreamManagementState> get copyWith =>
|
||||||
|
__$$_StreamManagementStateCopyWithImpl<_$_StreamManagementState>(
|
||||||
|
this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$$_StreamManagementStateToJson(
|
||||||
|
this,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _StreamManagementState implements StreamManagementState {
|
||||||
|
factory _StreamManagementState(final int c2s, final int s2c,
|
||||||
|
{final String? streamResumptionLocation,
|
||||||
|
final String? streamResumptionId}) = _$_StreamManagementState;
|
||||||
|
|
||||||
|
factory _StreamManagementState.fromJson(Map<String, dynamic> json) =
|
||||||
|
_$_StreamManagementState.fromJson;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get c2s;
|
||||||
|
@override
|
||||||
|
int get s2c;
|
||||||
|
@override
|
||||||
|
String? get streamResumptionLocation;
|
||||||
|
@override
|
||||||
|
String? get streamResumptionId;
|
||||||
|
@override
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
_$$_StreamManagementStateCopyWith<_$_StreamManagementState> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
25
moxxmpp/lib/src/xeps/xep_0198/state.g.dart
Normal file
25
moxxmpp/lib/src/xeps/xep_0198/state.g.dart
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'state.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_$_StreamManagementState _$$_StreamManagementStateFromJson(
|
||||||
|
Map<String, dynamic> json) =>
|
||||||
|
_$_StreamManagementState(
|
||||||
|
json['c2s'] as int,
|
||||||
|
json['s2c'] as int,
|
||||||
|
streamResumptionLocation: json['streamResumptionLocation'] as String?,
|
||||||
|
streamResumptionId: json['streamResumptionId'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$_StreamManagementStateToJson(
|
||||||
|
_$_StreamManagementState instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'c2s': instance.c2s,
|
||||||
|
's2c': instance.s2c,
|
||||||
|
'streamResumptionLocation': instance.streamResumptionLocation,
|
||||||
|
'streamResumptionId': instance.streamResumptionId,
|
||||||
|
};
|
393
moxxmpp/lib/src/xeps/xep_0198/xep_0198.dart
Normal file
393
moxxmpp/lib/src/xeps/xep_0198/xep_0198.dart
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:meta/meta.dart';
|
||||||
|
import 'package:moxxmpp/src/connection.dart';
|
||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0198/negotiator.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0198/nonzas.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0198/state.dart';
|
||||||
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
|
const xmlUintMax = 4294967296; // 2**32
|
||||||
|
|
||||||
|
typedef StanzaAckedCallback = bool Function(Stanza stanza);
|
||||||
|
|
||||||
|
class StreamManagementManager extends XmppManagerBase {
|
||||||
|
|
||||||
|
StreamManagementManager({
|
||||||
|
this.ackTimeout = const Duration(seconds: 30),
|
||||||
|
})
|
||||||
|
: _state = StreamManagementState(0, 0),
|
||||||
|
_unackedStanzas = {},
|
||||||
|
_stateLock = Lock(),
|
||||||
|
_streamManagementEnabled = false,
|
||||||
|
_lastAckTimestamp = -1,
|
||||||
|
_pendingAcks = 0,
|
||||||
|
_streamResumed = false,
|
||||||
|
_ackLock = Lock();
|
||||||
|
/// The queue of stanzas that are not (yet) acked
|
||||||
|
final Map<int, Stanza> _unackedStanzas;
|
||||||
|
/// Commitable state of the StreamManagementManager
|
||||||
|
StreamManagementState _state;
|
||||||
|
/// Mutex lock for _state
|
||||||
|
final Lock _stateLock;
|
||||||
|
/// If the have enabled SM on the stream yet
|
||||||
|
bool _streamManagementEnabled;
|
||||||
|
/// If the current stream has been resumed;
|
||||||
|
bool _streamResumed;
|
||||||
|
/// The time in which the response to an ack is still valid. Counts as a timeout
|
||||||
|
/// otherwise
|
||||||
|
@internal
|
||||||
|
final Duration ackTimeout;
|
||||||
|
/// The time at which the last ack has been sent
|
||||||
|
int _lastAckTimestamp;
|
||||||
|
/// The timer to see if we timed the connection out
|
||||||
|
Timer? _ackTimer;
|
||||||
|
/// Counts how many acks we're waiting for
|
||||||
|
int _pendingAcks;
|
||||||
|
/// Lock for both [_lastAckTimestamp] and [_pendingAcks].
|
||||||
|
final Lock _ackLock;
|
||||||
|
|
||||||
|
/// Functions for testing
|
||||||
|
@visibleForTesting
|
||||||
|
Map<int, Stanza> getUnackedStanzas() => _unackedStanzas;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Future<int> getPendingAcks() async {
|
||||||
|
var acks = 0;
|
||||||
|
|
||||||
|
await _ackLock.synchronized(() async {
|
||||||
|
acks = _pendingAcks;
|
||||||
|
});
|
||||||
|
|
||||||
|
return acks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when a stanza has been acked to decide whether we should trigger a
|
||||||
|
/// StanzaAckedEvent.
|
||||||
|
///
|
||||||
|
/// Return true when the stanza should trigger this event. Return false if not.
|
||||||
|
@visibleForOverriding
|
||||||
|
bool shouldTriggerAckedEvent(Stanza stanza) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async {
|
||||||
|
return getAttributes().getNegotiatorById<StreamManagementNegotiator>(streamManagementNegotiator)!.isSupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the amount of stanzas waiting to get acked
|
||||||
|
int getUnackedStanzaCount() => _unackedStanzas.length;
|
||||||
|
|
||||||
|
/// May be overwritten by a subclass. Should save [state] so that it can be loaded again
|
||||||
|
/// with [this.loadState].
|
||||||
|
Future<void> commitState() async {}
|
||||||
|
Future<void> loadState() async {}
|
||||||
|
|
||||||
|
Future<void> setState(StreamManagementState state) async {
|
||||||
|
await _stateLock.synchronized(() async {
|
||||||
|
_state = state;
|
||||||
|
await commitState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets the state such that a resumption is no longer possible without creating
|
||||||
|
/// a new session. Primarily useful for clearing the state after disconnecting
|
||||||
|
Future<void> resetState() async {
|
||||||
|
await setState(
|
||||||
|
_state.copyWith(
|
||||||
|
c2s: 0,
|
||||||
|
s2c: 0,
|
||||||
|
streamResumptionLocation: null,
|
||||||
|
streamResumptionId: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _ackLock.synchronized(() async {
|
||||||
|
_pendingAcks = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamManagementState get state => _state;
|
||||||
|
|
||||||
|
bool get streamResumed => _streamResumed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => smManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'StreamManagementManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<NonzaHandler> getNonzaHandlers() => [
|
||||||
|
NonzaHandler(
|
||||||
|
nonzaTag: 'r',
|
||||||
|
nonzaXmlns: smXmlns,
|
||||||
|
callback: _handleAckRequest,
|
||||||
|
),
|
||||||
|
NonzaHandler(
|
||||||
|
nonzaTag: 'a',
|
||||||
|
nonzaXmlns: smXmlns,
|
||||||
|
callback: _handleAckResponse,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
callback: _onServerStanzaReceived,
|
||||||
|
priority: 9999,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getOutgoingPostStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
callback: _onClientStanzaSent,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onXmppEvent(XmppEvent event) async {
|
||||||
|
if (event is StreamResumedEvent) {
|
||||||
|
_enableStreamManagement();
|
||||||
|
|
||||||
|
await _ackLock.synchronized(() async {
|
||||||
|
_pendingAcks = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
await onStreamResumed(event.h);
|
||||||
|
} else if (event is StreamManagementEnabledEvent) {
|
||||||
|
_enableStreamManagement();
|
||||||
|
|
||||||
|
await _ackLock.synchronized(() async {
|
||||||
|
_pendingAcks = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
await setState(
|
||||||
|
StreamManagementState(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
streamResumptionId: event.id,
|
||||||
|
streamResumptionLocation: event.location,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (event is ConnectingEvent) {
|
||||||
|
_disableStreamManagement();
|
||||||
|
_streamResumed = false;
|
||||||
|
} else if (event is ConnectionStateChangedEvent) {
|
||||||
|
if (event.state == XmppConnectionState.connected) {
|
||||||
|
// Push out all pending stanzas
|
||||||
|
await onStreamResumed(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the timer to detect timeouts based on ack responses, if the timer
|
||||||
|
/// is not already running.
|
||||||
|
void _startAckTimer() {
|
||||||
|
if (_ackTimer != null) return;
|
||||||
|
|
||||||
|
logger.fine('Starting ack timer');
|
||||||
|
_ackTimer = Timer.periodic(
|
||||||
|
ackTimeout,
|
||||||
|
_ackTimerCallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops the timer, if it is running.
|
||||||
|
void _stopAckTimer() {
|
||||||
|
if (_ackTimer == null) return;
|
||||||
|
|
||||||
|
logger.fine('Stopping ack timer');
|
||||||
|
_ackTimer!.cancel();
|
||||||
|
_ackTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Timer callback that checks if all acks have been answered. If not and the last
|
||||||
|
/// response has been more that [ackTimeout] in the past, declare the session dead.
|
||||||
|
void _ackTimerCallback(Timer timer) {
|
||||||
|
_ackLock.synchronized(() async {
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
|
if (now - _lastAckTimestamp >= ackTimeout.inMilliseconds && _pendingAcks > 0) {
|
||||||
|
_stopAckTimer();
|
||||||
|
await getAttributes().getConnection().reconnectionPolicy.onFailure();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper around sending an <r /> nonza that starts the ack timeout timer.
|
||||||
|
Future<void> _sendAckRequest() async {
|
||||||
|
logger.fine('_sendAckRequest: Waiting to acquire lock...');
|
||||||
|
await _ackLock.synchronized(() async {
|
||||||
|
logger.fine('_sendAckRequest: Done...');
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
|
_lastAckTimestamp = now;
|
||||||
|
_pendingAcks++;
|
||||||
|
_startAckTimer();
|
||||||
|
|
||||||
|
logger.fine('_pendingAcks is now at $_pendingAcks');
|
||||||
|
|
||||||
|
getAttributes().sendNonza(StreamManagementRequestNonza());
|
||||||
|
|
||||||
|
logger.fine('_sendAckRequest: Releasing lock...');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets the enablement of stream management, but __NOT__ the internal state.
|
||||||
|
/// This is to prevent ack requests being sent before we resume or re-enable
|
||||||
|
/// stream management.
|
||||||
|
void _disableStreamManagement() {
|
||||||
|
_streamManagementEnabled = false;
|
||||||
|
logger.finest('Stream Management disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enables support for XEP-0198 stream management
|
||||||
|
void _enableStreamManagement() {
|
||||||
|
_streamManagementEnabled = true;
|
||||||
|
logger.finest('Stream Management enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether XEP-0198 stream management is enabled
|
||||||
|
bool isStreamManagementEnabled() => _streamManagementEnabled;
|
||||||
|
|
||||||
|
/// To be called when receiving a <a /> nonza.
|
||||||
|
Future<bool> _handleAckRequest(XMLNode nonza) async {
|
||||||
|
final attrs = getAttributes();
|
||||||
|
logger.finest('Sending ack response');
|
||||||
|
await _stateLock.synchronized(() async {
|
||||||
|
attrs.sendNonza(StreamManagementAckNonza(_state.s2c));
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when we receive an <a /> nonza from the server.
|
||||||
|
/// This is a response to the question "How many of my stanzas have you handled".
|
||||||
|
Future<bool> _handleAckResponse(XMLNode nonza) async {
|
||||||
|
final h = int.parse(nonza.attributes['h']! as String);
|
||||||
|
|
||||||
|
await _ackLock.synchronized(() async {
|
||||||
|
await _stateLock.synchronized(() async {
|
||||||
|
if (_pendingAcks > 0) {
|
||||||
|
// Prevent diff from becoming negative
|
||||||
|
final diff = max(_state.c2s - h, 0);
|
||||||
|
_pendingAcks = diff;
|
||||||
|
} else {
|
||||||
|
_stopAckTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.fine('_pendingAcks is now at $_pendingAcks');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return early if we acked nothing.
|
||||||
|
// Taken from slixmpp's stream management code
|
||||||
|
logger.fine('_handleAckResponse: Waiting to aquire lock...');
|
||||||
|
await _stateLock.synchronized(() async {
|
||||||
|
logger.fine('_handleAckResponse: Done...');
|
||||||
|
if (h == _state.c2s && _unackedStanzas.isEmpty) {
|
||||||
|
logger.fine('_handleAckResponse: Releasing lock...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final attrs = getAttributes();
|
||||||
|
final sequences = _unackedStanzas.keys.toList()..sort();
|
||||||
|
for (final height in sequences) {
|
||||||
|
// Do nothing if the ack does not concern this stanza
|
||||||
|
if (height > h) continue;
|
||||||
|
|
||||||
|
final stanza = _unackedStanzas[height]!;
|
||||||
|
_unackedStanzas.remove(height);
|
||||||
|
|
||||||
|
// Create a StanzaAckedEvent if the stanza is correct
|
||||||
|
if (shouldTriggerAckedEvent(stanza)) {
|
||||||
|
attrs.sendEvent(StanzaAckedEvent(stanza));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h > _state.c2s) {
|
||||||
|
logger.info('C2S height jumped from ${_state.c2s} (local) to $h (remote).');
|
||||||
|
// ignore: cascade_invocations
|
||||||
|
logger.info('Proceeding with $h as local C2S counter.');
|
||||||
|
|
||||||
|
_state = _state.copyWith(c2s: h);
|
||||||
|
await commitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.fine('_handleAckResponse: Releasing lock...');
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just a helper function to not increment the counters above xmlUintMax
|
||||||
|
Future<void> _incrementC2S() async {
|
||||||
|
logger.fine('_incrementC2S: Waiting to aquire lock...');
|
||||||
|
await _stateLock.synchronized(() async {
|
||||||
|
logger.fine('_incrementC2S: Done');
|
||||||
|
_state = _state.copyWith(c2s: _state.c2s + 1 % xmlUintMax);
|
||||||
|
await commitState();
|
||||||
|
logger.fine('_incrementC2S: Releasing lock...');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Future<void> _incrementS2C() async {
|
||||||
|
logger.fine('_incrementS2C: Waiting to aquire lock...');
|
||||||
|
await _stateLock.synchronized(() async {
|
||||||
|
logger.fine('_incrementS2C: Done');
|
||||||
|
_state = _state.copyWith(s2c: _state.s2c + 1 % xmlUintMax);
|
||||||
|
await commitState();
|
||||||
|
logger.fine('_incrementS2C: Releasing lock...');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called whenever we receive a stanza from the server.
|
||||||
|
Future<StanzaHandlerData> _onServerStanzaReceived(Stanza stanza, StanzaHandlerData state) async {
|
||||||
|
await _incrementS2C();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called whenever we send a stanza.
|
||||||
|
Future<StanzaHandlerData> _onClientStanzaSent(Stanza stanza, StanzaHandlerData state) async {
|
||||||
|
await _incrementC2S();
|
||||||
|
_unackedStanzas[_state.c2s] = stanza;
|
||||||
|
|
||||||
|
if (isStreamManagementEnabled()) {
|
||||||
|
await _sendAckRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// To be called when the stream has been resumed
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> onStreamResumed(int h) async {
|
||||||
|
_streamResumed = true;
|
||||||
|
await _handleAckResponse(StreamManagementAckNonza(h));
|
||||||
|
|
||||||
|
final stanzas = _unackedStanzas.values.toList();
|
||||||
|
_unackedStanzas.clear();
|
||||||
|
|
||||||
|
// Retransmit the rest of the queue
|
||||||
|
final attrs = getAttributes();
|
||||||
|
for (final stanza in stanzas) {
|
||||||
|
await attrs.sendStanza(stanza, awaitable: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pings the connection open by send an ack request
|
||||||
|
void sendAckRequestPing() {
|
||||||
|
_sendAckRequest();
|
||||||
|
}
|
||||||
|
}
|
48
moxxmpp/lib/src/xeps/xep_0203.dart
Normal file
48
moxxmpp/lib/src/xeps/xep_0203.dart
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import 'package:meta/meta.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class DelayedDelivery {
|
||||||
|
|
||||||
|
const DelayedDelivery(this.from, this.timestamp);
|
||||||
|
final DateTime timestamp;
|
||||||
|
final String from;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DelayedDeliveryManager extends XmppManagerBase {
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => delayedDeliveryManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'DelayedDeliveryManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
callback: _onIncomingMessage,
|
||||||
|
priority: 200,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onIncomingMessage(Stanza stanza, StanzaHandlerData state) async {
|
||||||
|
final delay = stanza.firstTag('delay', xmlns: delayedDeliveryXmlns);
|
||||||
|
if (delay == null) return state;
|
||||||
|
|
||||||
|
return state.copyWith(
|
||||||
|
delayedDelivery: DelayedDelivery(
|
||||||
|
delay.attributes['from']! as String,
|
||||||
|
DateTime.parse(delay.attributes['stamp']! as String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
170
moxxmpp/lib/src/xeps/xep_0280.dart
Normal file
170
moxxmpp/lib/src/xeps/xep_0280.dart
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import 'package:meta/meta.dart';
|
||||||
|
import 'package:moxxmpp/src/connection.dart';
|
||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0297.dart';
|
||||||
|
|
||||||
|
class CarbonsManager extends XmppManagerBase {
|
||||||
|
|
||||||
|
CarbonsManager() : _isEnabled = false, _supported = false, _gotSupported = false, super();
|
||||||
|
bool _isEnabled;
|
||||||
|
bool _supported;
|
||||||
|
bool _gotSupported;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => carbonsManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'CarbonsManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
tagName: 'received',
|
||||||
|
tagXmlns: carbonsXmlns,
|
||||||
|
callback: _onMessageReceived,
|
||||||
|
// Before all managers the message manager depends on
|
||||||
|
priority: -98,
|
||||||
|
),
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
tagName: 'sent',
|
||||||
|
tagXmlns: carbonsXmlns,
|
||||||
|
callback: _onMessageSent,
|
||||||
|
// Before all managers the message manager depends on
|
||||||
|
priority: -98,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async {
|
||||||
|
if (_gotSupported) return _supported;
|
||||||
|
|
||||||
|
// Query the server
|
||||||
|
final disco = getAttributes().getManagerById<DiscoManager>(discoManager)!;
|
||||||
|
_supported = await disco.supportsFeature(
|
||||||
|
getAttributes().getConnectionSettings().jid.toBare(),
|
||||||
|
carbonsXmlns,
|
||||||
|
);
|
||||||
|
_gotSupported = true;
|
||||||
|
return _supported;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onXmppEvent(XmppEvent event) async {
|
||||||
|
if (event is ServerDiscoDoneEvent && !_isEnabled) {
|
||||||
|
final attrs = getAttributes();
|
||||||
|
|
||||||
|
if (attrs.isFeatureSupported(carbonsXmlns)) {
|
||||||
|
logger.finest('Message carbons supported. Enabling...');
|
||||||
|
await enableCarbons();
|
||||||
|
logger.finest('Message carbons enabled');
|
||||||
|
} else {
|
||||||
|
logger.info('Message carbons not supported.');
|
||||||
|
}
|
||||||
|
} else if (event is StreamResumeFailedEvent) {
|
||||||
|
_gotSupported = false;
|
||||||
|
_supported = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onMessageReceived(Stanza message, StanzaHandlerData state) async {
|
||||||
|
final from = JID.fromString(message.attributes['from']! as String);
|
||||||
|
final received = message.firstTag('received', xmlns: carbonsXmlns)!;
|
||||||
|
if (!isCarbonValid(from)) return state.copyWith(done: true);
|
||||||
|
|
||||||
|
final forwarded = received.firstTag('forwarded', xmlns: forwardedXmlns)!;
|
||||||
|
final carbon = unpackForwarded(forwarded);
|
||||||
|
|
||||||
|
return state.copyWith(
|
||||||
|
isCarbon: true,
|
||||||
|
stanza: carbon,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onMessageSent(Stanza message, StanzaHandlerData state) async {
|
||||||
|
final from = JID.fromString(message.attributes['from']! as String);
|
||||||
|
final sent = message.firstTag('sent', xmlns: carbonsXmlns)!;
|
||||||
|
if (!isCarbonValid(from)) return state.copyWith(done: true);
|
||||||
|
|
||||||
|
final forwarded = sent.firstTag('forwarded', xmlns: forwardedXmlns)!;
|
||||||
|
final carbon = unpackForwarded(forwarded);
|
||||||
|
|
||||||
|
return state.copyWith(
|
||||||
|
isCarbon: true,
|
||||||
|
stanza: carbon,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> enableCarbons() async {
|
||||||
|
final result = await getAttributes().sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
type: 'set',
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'enable',
|
||||||
|
xmlns: carbonsXmlns,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
addFrom: StanzaFromType.full,
|
||||||
|
addId: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.attributes['type'] != 'result') {
|
||||||
|
logger.warning('Failed to enable message carbons');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.fine('Successfully enabled message carbons');
|
||||||
|
|
||||||
|
_isEnabled = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> disableCarbons() async {
|
||||||
|
final result = await getAttributes().sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
type: 'set',
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'disable',
|
||||||
|
xmlns: carbonsXmlns,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
addFrom: StanzaFromType.full,
|
||||||
|
addId: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.attributes['type'] != 'result') {
|
||||||
|
logger.warning('Failed to disable message carbons');
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.fine('Successfully disabled message carbons');
|
||||||
|
|
||||||
|
_isEnabled = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
void forceEnable() {
|
||||||
|
_isEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isCarbonValid(JID senderJid) {
|
||||||
|
return _isEnabled && senderJid == getAttributes().getConnectionSettings().jid.toBare();
|
||||||
|
}
|
||||||
|
}
|
21
moxxmpp/lib/src/xeps/xep_0297.dart
Normal file
21
moxxmpp/lib/src/xeps/xep_0297.dart
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
/// Extracts the message stanza from the <forwarded /> node.
|
||||||
|
Stanza unpackForwarded(XMLNode forwarded) {
|
||||||
|
assert(forwarded.attributes['xmlns'] == forwardedXmlns, 'Invalid element xmlns');
|
||||||
|
assert(forwarded.tag == 'forwarded', 'Invalid element name');
|
||||||
|
|
||||||
|
// NOTE: We only use this XEP (for now) in the context of Message Carbons
|
||||||
|
final stanza = forwarded.firstTag('message', xmlns: stanzaXmlns)!;
|
||||||
|
return Stanza(
|
||||||
|
to: stanza.attributes['to']! as String,
|
||||||
|
from: stanza.attributes['from']! as String,
|
||||||
|
type: stanza.attributes['type']! as String,
|
||||||
|
id: stanza.attributes['id']! as String,
|
||||||
|
tag: stanza.tag,
|
||||||
|
attributes: stanza.attributes as Map<String, String>,
|
||||||
|
children: stanza.children,
|
||||||
|
);
|
||||||
|
}
|
104
moxxmpp/lib/src/xeps/xep_0300.dart
Normal file
104
moxxmpp/lib/src/xeps/xep_0300.dart
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import 'package:cryptography/cryptography.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
XMLNode constructHashElement(String algo, String base64Hash) {
|
||||||
|
return XMLNode.xmlns(
|
||||||
|
tag: 'hash',
|
||||||
|
xmlns: hashXmlns,
|
||||||
|
attributes: { 'algo': algo },
|
||||||
|
text: base64Hash,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HashFunction {
|
||||||
|
sha256,
|
||||||
|
sha512,
|
||||||
|
sha3_256,
|
||||||
|
sha3_512,
|
||||||
|
blake2b256,
|
||||||
|
blake2b512,
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HashNameToEnumExtension on HashFunction {
|
||||||
|
String toName() {
|
||||||
|
switch (this) {
|
||||||
|
case HashFunction.sha256:
|
||||||
|
return hashSha256;
|
||||||
|
case HashFunction.sha512:
|
||||||
|
return hashSha512;
|
||||||
|
case HashFunction.sha3_256:
|
||||||
|
return hashSha3512;
|
||||||
|
case HashFunction.sha3_512:
|
||||||
|
return hashSha3512;
|
||||||
|
case HashFunction.blake2b256:
|
||||||
|
return hashBlake2b256;
|
||||||
|
case HashFunction.blake2b512:
|
||||||
|
return hashBlake2b512;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HashFunction hashFunctionFromName(String name) {
|
||||||
|
switch (name) {
|
||||||
|
case hashSha256:
|
||||||
|
return HashFunction.sha256;
|
||||||
|
case hashSha512:
|
||||||
|
return HashFunction.sha512;
|
||||||
|
case hashSha3256:
|
||||||
|
return HashFunction.sha3_256;
|
||||||
|
case hashSha3512:
|
||||||
|
return HashFunction.sha3_512;
|
||||||
|
case hashBlake2b256:
|
||||||
|
return HashFunction.blake2b256;
|
||||||
|
case hashBlake2b512:
|
||||||
|
return HashFunction.blake2b512;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception();
|
||||||
|
}
|
||||||
|
|
||||||
|
class CryptographicHashManager extends XmppManagerBase {
|
||||||
|
@override
|
||||||
|
String getId() => cryptographicHashManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'CryptographicHashManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> getDiscoFeatures() => [
|
||||||
|
'$hashFunctionNameBaseXmlns:$hashSha256',
|
||||||
|
'$hashFunctionNameBaseXmlns:$hashSha512',
|
||||||
|
//'$hashFunctionNameBaseXmlns:$hashSha3256',
|
||||||
|
//'$hashFunctionNameBaseXmlns:$hashSha3512',
|
||||||
|
//'$hashFunctionNameBaseXmlns:$hashBlake2b256',
|
||||||
|
'$hashFunctionNameBaseXmlns:$hashBlake2b512',
|
||||||
|
];
|
||||||
|
|
||||||
|
static Future<List<int>> hashFromData(List<int> data, HashFunction function) async {
|
||||||
|
// TODO(PapaTutuWawa): Implemen the others as well
|
||||||
|
HashAlgorithm algo;
|
||||||
|
switch (function) {
|
||||||
|
case HashFunction.sha256:
|
||||||
|
algo = Sha256();
|
||||||
|
break;
|
||||||
|
case HashFunction.sha512:
|
||||||
|
algo = Sha512();
|
||||||
|
break;
|
||||||
|
case HashFunction.blake2b512:
|
||||||
|
algo = Blake2b();
|
||||||
|
break;
|
||||||
|
// ignore: no_default_cases
|
||||||
|
default:
|
||||||
|
throw Exception();
|
||||||
|
}
|
||||||
|
|
||||||
|
final digest = await algo.hash(data);
|
||||||
|
return digest.bytes;
|
||||||
|
}
|
||||||
|
}
|
69
moxxmpp/lib/src/xeps/xep_0333.dart
Normal file
69
moxxmpp/lib/src/xeps/xep_0333.dart
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
XMLNode makeChatMarkerMarkable() {
|
||||||
|
return XMLNode.xmlns(
|
||||||
|
tag: 'markable',
|
||||||
|
xmlns: chatMarkersXmlns,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
XMLNode makeChatMarker(String tag, String id) {
|
||||||
|
assert(['received', 'displayed', 'acknowledged'].contains(tag), 'Invalid chat marker');
|
||||||
|
return XMLNode.xmlns(
|
||||||
|
tag: tag,
|
||||||
|
xmlns: chatMarkersXmlns,
|
||||||
|
attributes: { 'id': id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatMarkerManager extends XmppManagerBase {
|
||||||
|
@override
|
||||||
|
String getName() => 'ChatMarkerManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => chatMarkerManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> getDiscoFeatures() => [ chatMarkersXmlns ];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
tagXmlns: chatMarkersXmlns,
|
||||||
|
callback: _onMessage,
|
||||||
|
// Before the message handler
|
||||||
|
priority: -99,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onMessage(Stanza message, StanzaHandlerData state) async {
|
||||||
|
final marker = message.firstTagByXmlns(chatMarkersXmlns)!;
|
||||||
|
|
||||||
|
// Handle the <markable /> explicitly
|
||||||
|
if (marker.tag == 'markable') return state.copyWith(isMarkable: true);
|
||||||
|
|
||||||
|
if (!['received', 'displayed', 'acknowledged'].contains(marker.tag)) {
|
||||||
|
logger.warning("Unknown message marker '${marker.tag}' found.");
|
||||||
|
} else {
|
||||||
|
getAttributes().sendEvent(ChatMarkerEvent(
|
||||||
|
from: JID.fromString(message.from!),
|
||||||
|
type: marker.tag,
|
||||||
|
id: marker.attributes['id']! as String,
|
||||||
|
),);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.copyWith(done: true);
|
||||||
|
}
|
||||||
|
}
|
36
moxxmpp/lib/src/xeps/xep_0334.dart
Normal file
36
moxxmpp/lib/src/xeps/xep_0334.dart
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
enum MessageProcessingHint {
|
||||||
|
noPermanentStore,
|
||||||
|
noStore,
|
||||||
|
noCopies,
|
||||||
|
store,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NOTE: We do not define a function for turning a Message Processing Hint element into
|
||||||
|
/// an enum value since the elements do not concern us as a client.
|
||||||
|
extension XmlExtension on MessageProcessingHint {
|
||||||
|
XMLNode toXml() {
|
||||||
|
String tag;
|
||||||
|
switch (this) {
|
||||||
|
case MessageProcessingHint.noPermanentStore:
|
||||||
|
tag = 'no-permanent-store';
|
||||||
|
break;
|
||||||
|
case MessageProcessingHint.noStore:
|
||||||
|
tag = 'no-store';
|
||||||
|
break;
|
||||||
|
case MessageProcessingHint.noCopies:
|
||||||
|
tag = 'no-copy';
|
||||||
|
break;
|
||||||
|
case MessageProcessingHint.store:
|
||||||
|
tag = 'store';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return XMLNode.xmlns(
|
||||||
|
tag: tag,
|
||||||
|
xmlns: messageProcessingHintsXmlns,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
96
moxxmpp/lib/src/xeps/xep_0352.dart
Normal file
96
moxxmpp/lib/src/xeps/xep_0352.dart
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/negotiators/negotiator.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
class CSIActiveNonza extends XMLNode {
|
||||||
|
CSIActiveNonza() : super(
|
||||||
|
tag: 'active',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'xmlns': csiXmlns
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CSIInactiveNonza extends XMLNode {
|
||||||
|
CSIInactiveNonza() : super(
|
||||||
|
tag: 'inactive',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'xmlns': csiXmlns
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Stub negotiator that is just for "intercepting" the stream feature.
|
||||||
|
class CSINegotiator extends XmppFeatureNegotiatorBase {
|
||||||
|
CSINegotiator() : _supported = false, super(11, false, csiXmlns, csiNegotiator);
|
||||||
|
|
||||||
|
/// True if CSI 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 CSI.
|
||||||
|
_supported = true;
|
||||||
|
state = NegotiatorState.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void reset() {
|
||||||
|
_supported = false;
|
||||||
|
|
||||||
|
super.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The manager requires a CSINegotiator to be registered as a feature negotiator.
|
||||||
|
class CSIManager extends XmppManagerBase {
|
||||||
|
|
||||||
|
CSIManager() : _isActive = true, super();
|
||||||
|
bool _isActive;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => csiManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'CSIManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async {
|
||||||
|
return getAttributes().getNegotiatorById<CSINegotiator>(csiNegotiator)!.isSupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// To be called after a stream has been resumed as CSI does not
|
||||||
|
/// survive a stream resumption.
|
||||||
|
void restoreCSIState() {
|
||||||
|
if (_isActive) {
|
||||||
|
setActive();
|
||||||
|
} else {
|
||||||
|
setInactive();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tells the server to top optimizing traffic
|
||||||
|
Future<void> setActive() async {
|
||||||
|
_isActive = true;
|
||||||
|
|
||||||
|
final attrs = getAttributes();
|
||||||
|
if (await isSupported()) {
|
||||||
|
attrs.sendNonza(CSIActiveNonza());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tells the server to optimize traffic following XEP-0352
|
||||||
|
Future<void> setInactive() async {
|
||||||
|
_isActive = false;
|
||||||
|
|
||||||
|
final attrs = getAttributes();
|
||||||
|
if (await isSupported()) {
|
||||||
|
attrs.sendNonza(CSIInactiveNonza());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
96
moxxmpp/lib/src/xeps/xep_0359.dart
Normal file
96
moxxmpp/lib/src/xeps/xep_0359.dart
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/types.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart';
|
||||||
|
|
||||||
|
/// Represents data provided by XEP-0359.
|
||||||
|
/// NOTE: [StableStanzaId.stanzaId] must not be confused with the actual id attribute of
|
||||||
|
/// the message stanza.
|
||||||
|
class StableStanzaId {
|
||||||
|
|
||||||
|
const StableStanzaId({ this.originId, this.stanzaId, this.stanzaIdBy });
|
||||||
|
final String? originId;
|
||||||
|
final String? stanzaId;
|
||||||
|
final String? stanzaIdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
XMLNode makeOriginIdElement(String id) {
|
||||||
|
return XMLNode.xmlns(
|
||||||
|
tag: 'origin-id',
|
||||||
|
xmlns: stableIdXmlns,
|
||||||
|
attributes: { 'id': id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class StableIdManager extends XmppManagerBase {
|
||||||
|
@override
|
||||||
|
String getName() => 'StableIdManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => stableIdManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> getDiscoFeatures() => [ stableIdXmlns ];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
callback: _onMessage,
|
||||||
|
// Before the MessageManager
|
||||||
|
priority: -99,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onMessage(Stanza message, StanzaHandlerData state) async {
|
||||||
|
final from = JID.fromString(message.attributes['from']! as String);
|
||||||
|
String? originId;
|
||||||
|
String? stanzaId;
|
||||||
|
String? stanzaIdBy;
|
||||||
|
final originIdTag = message.firstTag('origin-id', xmlns: stableIdXmlns);
|
||||||
|
final stanzaIdTag = message.firstTag('stanza-id', xmlns: stableIdXmlns);
|
||||||
|
if (originIdTag != null || stanzaIdTag != null) {
|
||||||
|
logger.finest('Found Unique and Stable Stanza Id tag');
|
||||||
|
final attrs = getAttributes();
|
||||||
|
final disco = attrs.getManagerById<DiscoManager>(discoManager)!;
|
||||||
|
final result = await disco.discoInfoQuery(from.toString());
|
||||||
|
if (result.isType<DiscoInfo>()) {
|
||||||
|
final info = result.get<DiscoInfo>();
|
||||||
|
logger.finest('Got info for ${from.toString()}');
|
||||||
|
if (info.features.contains(stableIdXmlns)) {
|
||||||
|
logger.finest('${from.toString()} supports $stableIdXmlns.');
|
||||||
|
|
||||||
|
if (originIdTag != null) {
|
||||||
|
originId = originIdTag.attributes['id']! as String;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stanzaIdTag != null) {
|
||||||
|
stanzaId = stanzaIdTag.attributes['id']! as String;
|
||||||
|
stanzaIdBy = stanzaIdTag.attributes['by']! as String;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.finest('${from.toString()} does not support $stableIdXmlns. Ignoring... ');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.finest('Failed to find out if ${from.toString()} supports $stableIdXmlns. Ignoring... ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.copyWith(
|
||||||
|
stableId: StableStanzaId(
|
||||||
|
originId: originId,
|
||||||
|
stanzaId: stanzaId,
|
||||||
|
stanzaIdBy: stanzaIdBy,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
180
moxxmpp/lib/src/xeps/xep_0363.dart
Normal file
180
moxxmpp/lib/src/xeps/xep_0363.dart
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import 'package:meta/meta.dart';
|
||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/types/error.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/errors.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/types.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart';
|
||||||
|
|
||||||
|
const errorNoUploadServer = 1;
|
||||||
|
const errorFileTooBig = 2;
|
||||||
|
const errorGeneric = 3;
|
||||||
|
|
||||||
|
const allowedHTTPHeaders = [ 'authorization', 'cookie', 'expires' ];
|
||||||
|
|
||||||
|
class HttpFileUploadSlot {
|
||||||
|
|
||||||
|
const HttpFileUploadSlot(this.putUrl, this.getUrl, this.headers);
|
||||||
|
final String putUrl;
|
||||||
|
final String getUrl;
|
||||||
|
final Map<String, String> headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strips out all newlines from [value].
|
||||||
|
String _stripNewlinesFromString(String value) {
|
||||||
|
return value.replaceAll('\n', '').replaceAll('\r', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepares a list of headers by removing newlines from header names and values
|
||||||
|
/// and also removes any headers that are not allowed by the XEP.
|
||||||
|
@visibleForTesting
|
||||||
|
Map<String, String> prepareHeaders(Map<String, String> headers) {
|
||||||
|
return headers.map((key, value) {
|
||||||
|
return MapEntry(
|
||||||
|
_stripNewlinesFromString(key),
|
||||||
|
_stripNewlinesFromString(value),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
..removeWhere((key, _) => !allowedHTTPHeaders.contains(key.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpFileUploadManager extends XmppManagerBase {
|
||||||
|
|
||||||
|
HttpFileUploadManager() : _gotSupported = false, _supported = false, super();
|
||||||
|
JID? _entityJid;
|
||||||
|
int? _maxUploadSize;
|
||||||
|
bool _gotSupported;
|
||||||
|
bool _supported;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => httpFileUploadManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'HttpFileUploadManager';
|
||||||
|
|
||||||
|
/// Returns whether the entity provided an identity that tells us that we can ask it
|
||||||
|
/// for an HTTP upload slot.
|
||||||
|
bool _containsFileUploadIdentity(DiscoInfo info) {
|
||||||
|
return listContains(info.identities, (Identity id) => id.category == 'store' && id.type == 'file');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the maximum filesize in octets from the disco response. Returns null
|
||||||
|
/// if none was specified.
|
||||||
|
int? _getMaxFileSize(DiscoInfo info) {
|
||||||
|
for (final form in info.extendedInfo) {
|
||||||
|
for (final field in form.fields) {
|
||||||
|
if (field.varAttr == 'max-file-size') {
|
||||||
|
return int.parse(field.values.first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onXmppEvent(XmppEvent event) async {
|
||||||
|
if (event is StreamResumeFailedEvent) {
|
||||||
|
_gotSupported = false;
|
||||||
|
_supported = false;
|
||||||
|
_entityJid = null;
|
||||||
|
_maxUploadSize = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async {
|
||||||
|
if (_gotSupported) return _supported;
|
||||||
|
|
||||||
|
final result = await getAttributes().getManagerById<DiscoManager>(discoManager)!.performDiscoSweep();
|
||||||
|
if (result.isType<DiscoError>()) {
|
||||||
|
_gotSupported = false;
|
||||||
|
_supported = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final infos = result.get<List<DiscoInfo>>();
|
||||||
|
_gotSupported = true;
|
||||||
|
for (final info in infos) {
|
||||||
|
if (_containsFileUploadIdentity(info) && info.features.contains(httpFileUploadXmlns)) {
|
||||||
|
logger.info('Discovered HTTP File Upload for ${info.jid}');
|
||||||
|
|
||||||
|
_entityJid = info.jid;
|
||||||
|
_maxUploadSize = _getMaxFileSize(info);
|
||||||
|
_supported = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _supported;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request a slot to upload a file to. [filename] is the file's name and [filesize] is
|
||||||
|
/// the file's size in octets. [contentType] is optional and refers to the file's
|
||||||
|
/// Mime type.
|
||||||
|
/// Returns an [HttpFileUploadSlot] if the request was successful; null otherwise.
|
||||||
|
Future<MayFail<HttpFileUploadSlot>> requestUploadSlot(String filename, int filesize, { String? contentType }) async {
|
||||||
|
if (!(await isSupported())) return MayFail.failure(errorNoUploadServer);
|
||||||
|
|
||||||
|
if (_entityJid == null) {
|
||||||
|
logger.warning('Attempted to request HTTP File Upload slot but no entity is known to send this request to.');
|
||||||
|
return MayFail.failure(errorNoUploadServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_maxUploadSize != null && filesize > _maxUploadSize!) {
|
||||||
|
logger.warning('Attempted to request HTTP File Upload slot for a file that exceeds the filesize limit');
|
||||||
|
return MayFail.failure(errorFileTooBig);
|
||||||
|
}
|
||||||
|
|
||||||
|
final attrs = getAttributes();
|
||||||
|
final response = await attrs.sendStanza(
|
||||||
|
Stanza.iq(
|
||||||
|
to: _entityJid.toString(),
|
||||||
|
type: 'get',
|
||||||
|
children: [
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'request',
|
||||||
|
xmlns: httpFileUploadXmlns,
|
||||||
|
attributes: {
|
||||||
|
'filename': filename,
|
||||||
|
'size': filesize.toString(),
|
||||||
|
...contentType != null ? { 'content-type': contentType } : {}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.attributes['type']! != 'result') {
|
||||||
|
logger.severe('Failed to request HTTP File Upload slot.');
|
||||||
|
// TODO(Unknown): Be more precise
|
||||||
|
return MayFail.failure(errorGeneric);
|
||||||
|
}
|
||||||
|
|
||||||
|
final slot = response.firstTag('slot', xmlns: httpFileUploadXmlns)!;
|
||||||
|
final putUrl = slot.firstTag('put')!.attributes['url']! as String;
|
||||||
|
final getUrl = slot.firstTag('get')!.attributes['url']! as String;
|
||||||
|
final headers = Map<String, String>.fromEntries(
|
||||||
|
slot.findTags('header').map((tag) {
|
||||||
|
return MapEntry(
|
||||||
|
tag.attributes['name']! as String,
|
||||||
|
tag.innerText(),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return MayFail.success(
|
||||||
|
HttpFileUploadSlot(
|
||||||
|
putUrl,
|
||||||
|
getUrl,
|
||||||
|
prepareHeaders(headers),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
91
moxxmpp/lib/src/xeps/xep_0380.dart
Normal file
91
moxxmpp/lib/src/xeps/xep_0380.dart
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
enum ExplicitEncryptionType {
|
||||||
|
otr,
|
||||||
|
legacyOpenPGP,
|
||||||
|
openPGP,
|
||||||
|
omemo,
|
||||||
|
omemo1,
|
||||||
|
omemo2,
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
String _explicitEncryptionTypeToString(ExplicitEncryptionType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ExplicitEncryptionType.otr: return emeOtr;
|
||||||
|
case ExplicitEncryptionType.legacyOpenPGP: return emeLegacyOpenPGP;
|
||||||
|
case ExplicitEncryptionType.openPGP: return emeOpenPGP;
|
||||||
|
case ExplicitEncryptionType.omemo: return emeOmemo;
|
||||||
|
case ExplicitEncryptionType.omemo1: return emeOmemo1;
|
||||||
|
case ExplicitEncryptionType.omemo2: return emeOmemo2;
|
||||||
|
case ExplicitEncryptionType.unknown: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ExplicitEncryptionType _explicitEncryptionTypeFromString(String str) {
|
||||||
|
switch (str) {
|
||||||
|
case emeOtr: return ExplicitEncryptionType.otr;
|
||||||
|
case emeLegacyOpenPGP: return ExplicitEncryptionType.legacyOpenPGP;
|
||||||
|
case emeOpenPGP: return ExplicitEncryptionType.openPGP;
|
||||||
|
case emeOmemo: return ExplicitEncryptionType.omemo;
|
||||||
|
case emeOmemo1: return ExplicitEncryptionType.omemo1;
|
||||||
|
case emeOmemo2: return ExplicitEncryptionType.omemo2;
|
||||||
|
default: return ExplicitEncryptionType.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an <encryption /> element with [type] indicating which type of encryption was
|
||||||
|
/// used.
|
||||||
|
XMLNode buildEmeElement(ExplicitEncryptionType type) {
|
||||||
|
return XMLNode.xmlns(
|
||||||
|
tag: 'encryption',
|
||||||
|
xmlns: emeXmlns,
|
||||||
|
attributes: <String, String>{
|
||||||
|
'namespace': _explicitEncryptionTypeToString(type),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmeManager extends XmppManagerBase {
|
||||||
|
|
||||||
|
EmeManager() : super();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => emeManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'EmeManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> getDiscoFeatures() => [emeXmlns];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
tagName: 'encryption',
|
||||||
|
tagXmlns: emeXmlns,
|
||||||
|
callback: _onStanzaReceived,
|
||||||
|
// Before the message handler
|
||||||
|
priority: -99,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onStanzaReceived(Stanza message, StanzaHandlerData state) async {
|
||||||
|
final encryption = message.firstTag('encryption', xmlns: emeXmlns)!;
|
||||||
|
|
||||||
|
return state.copyWith(
|
||||||
|
encryptionType: _explicitEncryptionTypeFromString(
|
||||||
|
encryption.attributes['namespace']! as String,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
20
moxxmpp/lib/src/xeps/xep_0384/crypto.dart
Normal file
20
moxxmpp/lib/src/xeps/xep_0384/crypto.dart
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
|
||||||
|
/// Checks the OMEMO affix elements. [envelope] refers to the <envelope /> element we get
|
||||||
|
/// after decrypting the payload. [sender] refers to the "to" attribute of the stanza.
|
||||||
|
/// [ourJid] is our current full Jid.
|
||||||
|
///
|
||||||
|
/// Returns true if the affix elements are all valid and as expected. Returns false if not.
|
||||||
|
bool checkAffixElements(XMLNode envelope, String sender, JID ourJid) {
|
||||||
|
final from = envelope.firstTag('from')?.attributes['jid'] as String?;
|
||||||
|
if (from == null) return false;
|
||||||
|
final encSender = JID.fromString(from);
|
||||||
|
|
||||||
|
final to = envelope.firstTag('to')?.attributes['jid'] as String?;
|
||||||
|
if (to == null) return false;
|
||||||
|
final encReceiver = JID.fromString(to);
|
||||||
|
|
||||||
|
return encSender.toBare().toString() == JID.fromString(sender).toBare().toString() &&
|
||||||
|
encReceiver.toBare().toString() == ourJid.toBare().toString();
|
||||||
|
}
|
9
moxxmpp/lib/src/xeps/xep_0384/errors.dart
Normal file
9
moxxmpp/lib/src/xeps/xep_0384/errors.dart
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
abstract class OmemoError {}
|
||||||
|
|
||||||
|
class UnknownOmemoError extends OmemoError {}
|
||||||
|
|
||||||
|
class InvalidAffixElementsException with Exception {}
|
||||||
|
|
||||||
|
class OmemoNotSupportedForContactException extends OmemoError {}
|
||||||
|
|
||||||
|
class EncryptionFailedException with Exception {}
|
82
moxxmpp/lib/src/xeps/xep_0384/helpers.dart
Normal file
82
moxxmpp/lib/src/xeps/xep_0384/helpers.dart
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:omemo_dart/omemo_dart.dart';
|
||||||
|
import 'package:random_string/random_string.dart';
|
||||||
|
|
||||||
|
/// Generate a random alpha-numeric string with a random length between 0 and 200 in
|
||||||
|
/// accordance to XEP-0420's rpad affix element.
|
||||||
|
String generateRpad() {
|
||||||
|
final random = Random.secure();
|
||||||
|
final length = random.nextInt(200);
|
||||||
|
return randomAlphaNumeric(length, provider: CoreRandomProvider.from(random));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert the XML representation of an OMEMO bundle into an OmemoBundle object.
|
||||||
|
/// [jid] refers to the JID the bundle belongs to. [id] refers to the bundle's device
|
||||||
|
/// identifier. [bundle] refers to the <bundle /> element.
|
||||||
|
///
|
||||||
|
/// Returns the OmemoBundle.
|
||||||
|
OmemoBundle bundleFromXML(JID jid, int id, XMLNode bundle) {
|
||||||
|
assert(bundle.attributes['xmlns'] == omemoXmlns, 'Invalid xmlns');
|
||||||
|
|
||||||
|
final spk = bundle.firstTag('spk')!;
|
||||||
|
final prekeys = <int, String>{};
|
||||||
|
for (final pk in bundle.firstTag('prekeys')!.findTags('pk')) {
|
||||||
|
prekeys[int.parse(pk.attributes['id']! as String)] = pk.innerText();
|
||||||
|
}
|
||||||
|
|
||||||
|
return OmemoBundle(
|
||||||
|
jid.toBare().toString(),
|
||||||
|
id,
|
||||||
|
spk.innerText(),
|
||||||
|
int.parse(spk.attributes['id']! as String),
|
||||||
|
bundle.firstTag('spks')!.innerText(),
|
||||||
|
bundle.firstTag('ik')!.innerText(),
|
||||||
|
prekeys,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts an OmemoBundle [bundle] into its XML representation.
|
||||||
|
///
|
||||||
|
/// Returns the XML element.
|
||||||
|
XMLNode bundleToXML(OmemoBundle bundle) {
|
||||||
|
final prekeys = List<XMLNode>.empty(growable: true);
|
||||||
|
for (final pk in bundle.opksEncoded.entries) {
|
||||||
|
prekeys.add(
|
||||||
|
XMLNode(
|
||||||
|
tag: 'pk', attributes: <String, String>{
|
||||||
|
'id': '${pk.key}',
|
||||||
|
},
|
||||||
|
text: pk.value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return XMLNode.xmlns(
|
||||||
|
tag: 'bundle',
|
||||||
|
xmlns: omemoXmlns,
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'spk',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'id': '${bundle.spkId}',
|
||||||
|
},
|
||||||
|
text: bundle.spkEncoded,
|
||||||
|
),
|
||||||
|
XMLNode(
|
||||||
|
tag: 'spks',
|
||||||
|
text: bundle.spkSignatureEncoded,
|
||||||
|
),
|
||||||
|
XMLNode(
|
||||||
|
tag: 'ik',
|
||||||
|
text: bundle.ikEncoded,
|
||||||
|
),
|
||||||
|
XMLNode(
|
||||||
|
tag: 'prekeys',
|
||||||
|
children: prekeys,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
7
moxxmpp/lib/src/xeps/xep_0384/types.dart
Normal file
7
moxxmpp/lib/src/xeps/xep_0384/types.dart
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/// A simple wrapper class for defining elements that should not be encrypted.
|
||||||
|
class DoNotEncrypt {
|
||||||
|
|
||||||
|
const DoNotEncrypt(this.tag, this.xmlns);
|
||||||
|
final String tag;
|
||||||
|
final String xmlns;
|
||||||
|
}
|
953
moxxmpp/lib/src/xeps/xep_0384/xep_0384.dart
Normal file
953
moxxmpp/lib/src/xeps/xep_0384/xep_0384.dart
Normal file
@ -0,0 +1,953 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:meta/meta.dart';
|
||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
|
import 'package:moxxmpp/src/events.dart';
|
||||||
|
import 'package:moxxmpp/src/jid.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/types/resultv2.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/errors.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/types.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0060/errors.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0060/xep_0060.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0334.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0380.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0384/crypto.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0384/errors.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0384/helpers.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0384/types.dart';
|
||||||
|
import 'package:omemo_dart/omemo_dart.dart';
|
||||||
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
|
const _doNotEncryptList = [
|
||||||
|
// XEP-0033
|
||||||
|
DoNotEncrypt('addresses', extendedAddressingXmlns),
|
||||||
|
// XEP-0060
|
||||||
|
DoNotEncrypt('pubsub', pubsubXmlns),
|
||||||
|
DoNotEncrypt('pubsub', pubsubOwnerXmlns),
|
||||||
|
// XEP-0334
|
||||||
|
DoNotEncrypt('no-permanent-store', messageProcessingHintsXmlns),
|
||||||
|
DoNotEncrypt('no-store', messageProcessingHintsXmlns),
|
||||||
|
DoNotEncrypt('no-copy', messageProcessingHintsXmlns),
|
||||||
|
DoNotEncrypt('store', messageProcessingHintsXmlns),
|
||||||
|
// XEP-0359
|
||||||
|
DoNotEncrypt('origin-id', stableIdXmlns),
|
||||||
|
DoNotEncrypt('stanza-id', stableIdXmlns),
|
||||||
|
];
|
||||||
|
|
||||||
|
abstract class OmemoManager extends XmppManagerBase {
|
||||||
|
|
||||||
|
OmemoManager() : _handlerLock = Lock(), _handlerFutures = {}, super();
|
||||||
|
|
||||||
|
final Lock _handlerLock;
|
||||||
|
final Map<JID, Queue<Completer<void>>> _handlerFutures;
|
||||||
|
|
||||||
|
final Map<JID, List<int>> _deviceMap = {};
|
||||||
|
|
||||||
|
// Mapping whether we already tried to subscribe to the JID's devices node
|
||||||
|
final Map<JID, bool> _subscriptionMap = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => omemoManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getName() => 'OmemoManager';
|
||||||
|
|
||||||
|
// TODO(Unknown): Technically, this is not always true
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'iq',
|
||||||
|
tagXmlns: omemoXmlns,
|
||||||
|
tagName: 'encrypted',
|
||||||
|
callback: _onIncomingStanza,
|
||||||
|
priority: 9999,
|
||||||
|
),
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'presence',
|
||||||
|
tagXmlns: omemoXmlns,
|
||||||
|
tagName: 'encrypted',
|
||||||
|
callback: _onIncomingStanza,
|
||||||
|
priority: 9999,
|
||||||
|
),
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
tagXmlns: omemoXmlns,
|
||||||
|
tagName: 'encrypted',
|
||||||
|
callback: _onIncomingStanza,
|
||||||
|
priority: -98,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getOutgoingPreStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'iq',
|
||||||
|
callback: _onOutgoingStanza,
|
||||||
|
),
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'presence',
|
||||||
|
callback: _onOutgoingStanza,
|
||||||
|
),
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
callback: _onOutgoingStanza,
|
||||||
|
priority: 100,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> onXmppEvent(XmppEvent event) async {
|
||||||
|
if (event is PubSubNotificationEvent) {
|
||||||
|
if (event.item.node != omemoDevicesXmlns) return;
|
||||||
|
|
||||||
|
logger.finest('Received PubSub device notification for ${event.from}');
|
||||||
|
final ownJid = getAttributes().getFullJID().toBare().toString();
|
||||||
|
final jid = JID.fromString(event.from).toBare();
|
||||||
|
final ids = event.item.payload.children
|
||||||
|
.map((child) => int.parse(child.attributes['id']! as String))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (event.from == ownJid) {
|
||||||
|
// Another client published to our device list node
|
||||||
|
if (!ids.contains(await _getDeviceId())) {
|
||||||
|
// Attempt to publish again
|
||||||
|
unawaited(publishBundle(await _getDeviceBundle()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Someone published to their device list node
|
||||||
|
logger.finest('Got devices $ids');
|
||||||
|
_deviceMap[jid] = ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate an event
|
||||||
|
getAttributes().sendEvent(OmemoDeviceListUpdatedEvent(jid, ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForOverriding
|
||||||
|
Future<OmemoSessionManager> getSessionManager();
|
||||||
|
|
||||||
|
/// Wrapper around using getSessionManager and then calling encryptToJids on it.
|
||||||
|
Future<EncryptionResult> _encryptToJids(List<String> jids, String? plaintext, { List<OmemoBundle>? newSessions }) async {
|
||||||
|
final session = await getSessionManager();
|
||||||
|
return session.encryptToJids(jids, plaintext, newSessions: newSessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper around using getSessionManager and then calling encryptToJids on it.
|
||||||
|
Future<String?> _decryptMessage(List<int>? ciphertext, String senderJid, int senderDeviceId, List<EncryptedKey> keys, int sendTimestamp) async {
|
||||||
|
final session = await getSessionManager();
|
||||||
|
return session.decryptMessage(
|
||||||
|
ciphertext,
|
||||||
|
senderJid,
|
||||||
|
senderDeviceId,
|
||||||
|
keys,
|
||||||
|
sendTimestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper around using getSessionManager and then calling getDeviceId on it.
|
||||||
|
Future<int> _getDeviceId() async {
|
||||||
|
final session = await getSessionManager();
|
||||||
|
return session.getDeviceId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper around using getSessionManager and then calling getDeviceId on it.
|
||||||
|
Future<OmemoBundle> _getDeviceBundle() async {
|
||||||
|
final session = await getSessionManager();
|
||||||
|
return session.getDeviceBundle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper around using getSessionManager and then calling isRatchetAcknowledged on it.
|
||||||
|
Future<bool> _isRatchetAcknowledged(String jid, int deviceId) async {
|
||||||
|
final session = await getSessionManager();
|
||||||
|
return session.isRatchetAcknowledged(jid, deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper around checking if [jid] appears in the session manager's device map.
|
||||||
|
Future<bool> _hasSessionWith(String jid) async {
|
||||||
|
final session = await getSessionManager();
|
||||||
|
final deviceMap = await session.getDeviceMap();
|
||||||
|
return deviceMap.containsKey(jid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determines what child elements of a stanza should be encrypted. If shouldEncrypt
|
||||||
|
/// returns true for [element], then [element] will be encrypted. If shouldEncrypt
|
||||||
|
/// returns false, then [element] won't be encrypted.
|
||||||
|
///
|
||||||
|
/// The default implementation ignores all elements that are mentioned in XEP-0420, i.e.:
|
||||||
|
/// - XEP-0033 elements (<addresses />)
|
||||||
|
/// - XEP-0334 elements (<store/>, <no-copy/>, <no-store/>, <no-permanent-store/>)
|
||||||
|
/// - XEP-0359 elements (<origin-id />, <stanza-id />)
|
||||||
|
@visibleForOverriding
|
||||||
|
bool shouldEncryptElement(XMLNode element) {
|
||||||
|
for (final ignore in _doNotEncryptList) {
|
||||||
|
final xmlns = element.attributes['xmlns'] ?? '';
|
||||||
|
if (element.tag == ignore.tag && xmlns == ignore.xmlns) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt [children] using OMEMO. This either produces an <encrypted /> element with
|
||||||
|
/// an attached payload, if [children] is not null, or an empty OMEMO message if
|
||||||
|
/// [children] is null. This function takes care of creating the affix elements as
|
||||||
|
/// specified by both XEP-0420 and XEP-0384.
|
||||||
|
/// [jids] is the list of JIDs the payload should be encrypted for.
|
||||||
|
Future<XMLNode> _encryptChildren(List<XMLNode>? children, List<String> jids, String toJid, List<OmemoBundle> newSessions) async {
|
||||||
|
XMLNode? payload;
|
||||||
|
if (children != null) {
|
||||||
|
payload = XMLNode.xmlns(
|
||||||
|
tag: 'envelope',
|
||||||
|
xmlns: sceXmlns,
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'content',
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
|
||||||
|
XMLNode(
|
||||||
|
tag: 'rpad',
|
||||||
|
text: generateRpad(),
|
||||||
|
),
|
||||||
|
XMLNode(
|
||||||
|
tag: 'to',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'jid': toJid,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
XMLNode(
|
||||||
|
tag: 'from',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'jid': getAttributes().getFullJID().toString(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
/*
|
||||||
|
XMLNode(
|
||||||
|
tag: 'time',
|
||||||
|
// TODO(Unknown): Implement
|
||||||
|
attributes: <String, String>{
|
||||||
|
'stamp': '',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
*/
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final encryptedEnvelope = await _encryptToJids(
|
||||||
|
jids,
|
||||||
|
payload?.toXml(),
|
||||||
|
newSessions: newSessions,
|
||||||
|
);
|
||||||
|
|
||||||
|
final keyElements = <String, List<XMLNode>>{};
|
||||||
|
for (final key in encryptedEnvelope.encryptedKeys) {
|
||||||
|
final keyElement = XMLNode(
|
||||||
|
tag: 'key',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'rid': '${key.rid}',
|
||||||
|
'kex': key.kex ? 'true' : 'false',
|
||||||
|
},
|
||||||
|
text: key.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (keyElements.containsKey(key.jid)) {
|
||||||
|
keyElements[key.jid]!.add(keyElement);
|
||||||
|
} else {
|
||||||
|
keyElements[key.jid] = [keyElement];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final keysElements = keyElements.entries.map((entry) {
|
||||||
|
return XMLNode(
|
||||||
|
tag: 'keys',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'jid': entry.key,
|
||||||
|
},
|
||||||
|
children: entry.value,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
var payloadElement = <XMLNode>[];
|
||||||
|
if (payload != null) {
|
||||||
|
payloadElement = [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'payload',
|
||||||
|
text: base64.encode(encryptedEnvelope.ciphertext!),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return XMLNode.xmlns(
|
||||||
|
tag: 'encrypted',
|
||||||
|
xmlns: omemoXmlns,
|
||||||
|
children: [
|
||||||
|
...payloadElement,
|
||||||
|
XMLNode(
|
||||||
|
tag: 'header',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'sid': (await _getDeviceId()).toString(),
|
||||||
|
},
|
||||||
|
children: keysElements,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A logging wrapper around acking the ratchet with [jid] with identifier [deviceId].
|
||||||
|
Future<void> _ackRatchet(String jid, int deviceId) async {
|
||||||
|
logger.finest('Acking ratchet $jid:$deviceId');
|
||||||
|
final session = await getSessionManager();
|
||||||
|
await session.ratchetAcknowledged(jid, deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Figure out if new sessions need to be built. [toJid] is the JID of the entity we
|
||||||
|
/// want to send a message to. [children] refers to the unencrypted children of the
|
||||||
|
/// message. They are required to be passed because shouldIgnoreUnackedRatchets is
|
||||||
|
/// called here.
|
||||||
|
///
|
||||||
|
/// Either returns a list of bundles we "need" to build a session with or an OmemoError.
|
||||||
|
Future<Result<OmemoError, List<OmemoBundle>>> _findNewSessions(JID toJid, List<XMLNode> children) async {
|
||||||
|
final ownJid = getAttributes().getFullJID().toBare();
|
||||||
|
final session = await getSessionManager();
|
||||||
|
final ownId = await session.getDeviceId();
|
||||||
|
|
||||||
|
// Ignore our own device if it is the only published device on our devices node
|
||||||
|
if (toJid.toBare() == ownJid) {
|
||||||
|
final deviceList = await getDeviceList(ownJid);
|
||||||
|
if (deviceList.isType<List<int>>()) {
|
||||||
|
final devices = deviceList.get<List<int>>();
|
||||||
|
if (devices.length == 1 && devices.first == ownId) {
|
||||||
|
return const Result(<OmemoBundle>[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final newSessions = List<OmemoBundle>.empty(growable: true);
|
||||||
|
final sessionAvailable = await _hasSessionWith(toJid.toString());
|
||||||
|
if (!sessionAvailable) {
|
||||||
|
logger.finest('No session for $toJid. Retrieving bundles to build a new session.');
|
||||||
|
final result = await retrieveDeviceBundles(toJid);
|
||||||
|
if (result.isType<List<OmemoBundle>>()) {
|
||||||
|
final bundles = result.get<List<OmemoBundle>>();
|
||||||
|
|
||||||
|
if (ownJid == toJid) {
|
||||||
|
logger.finest('Requesting bundles for own JID. Ignoring current device');
|
||||||
|
newSessions.addAll(bundles.where((bundle) => bundle.id != ownId));
|
||||||
|
} else {
|
||||||
|
newSessions.addAll(bundles);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warning('Failed to retrieve device bundles for $toJid');
|
||||||
|
return Result(OmemoNotSupportedForContactException());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_subscriptionMap.containsKey(toJid)) {
|
||||||
|
await subscribeToDeviceList(toJid);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final toBare = toJid.toBare();
|
||||||
|
final ratchetSessions = (await session.getDeviceMap())[toBare.toString()]!;
|
||||||
|
final deviceMapRaw = await getDeviceList(toBare);
|
||||||
|
if (!_subscriptionMap.containsKey(toBare)) {
|
||||||
|
unawaited(subscribeToDeviceList(toBare));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceMapRaw.isType<OmemoError>()) {
|
||||||
|
logger.warning('Failed to get device list');
|
||||||
|
return Result(UnknownOmemoError());
|
||||||
|
}
|
||||||
|
|
||||||
|
final deviceList = deviceMapRaw.get<List<int>>();
|
||||||
|
for (final id in deviceList) {
|
||||||
|
// We already have a session with that device
|
||||||
|
if (ratchetSessions.contains(id)) continue;
|
||||||
|
|
||||||
|
// Ignore requests for our own device.
|
||||||
|
if (toJid == ownJid && id == ownId) {
|
||||||
|
logger.finest('Attempted to request bundle for our own device $id, which is the current device. Skipping request...');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.finest('Retrieving bundle for $toJid:$id');
|
||||||
|
final bundle = await retrieveDeviceBundle(toJid, id);
|
||||||
|
if (bundle.isType<OmemoBundle>()) {
|
||||||
|
newSessions.add(bundle.get<OmemoBundle>());
|
||||||
|
} else {
|
||||||
|
logger.warning('Failed to retrieve bundle for $toJid:$id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result(newSessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends an empty Omemo message to [toJid].
|
||||||
|
///
|
||||||
|
/// If [findNewSessions] is true, then
|
||||||
|
/// new devices will be looked for first before sending the message. This means that
|
||||||
|
/// the new sessions will be included in the empty Omemo message. If false, then no
|
||||||
|
/// new sessions will be looked for before encrypting.
|
||||||
|
///
|
||||||
|
/// [calledFromCriticalSection] MUST NOT be used from outside the manager. If true, then
|
||||||
|
/// sendEmptyMessage will not attempt to enter the critical section guarding the
|
||||||
|
/// encryption and decryption. If false, then the critical section will be entered before
|
||||||
|
/// encryption and left after sending the message.
|
||||||
|
Future<void> sendEmptyMessage(JID toJid, {
|
||||||
|
bool findNewSessions = false,
|
||||||
|
@protected
|
||||||
|
bool calledFromCriticalSection = false,
|
||||||
|
}) async {
|
||||||
|
if (!calledFromCriticalSection) {
|
||||||
|
final completer = await _handlerEntry(toJid);
|
||||||
|
if (completer != null) {
|
||||||
|
await completer.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var newSessions = <OmemoBundle>[];
|
||||||
|
if (findNewSessions) {
|
||||||
|
final result = await _findNewSessions(toJid, <XMLNode>[]);
|
||||||
|
if (!result.isType<OmemoError>()) newSessions = result.get<List<OmemoBundle>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
final empty = await _encryptChildren(
|
||||||
|
null,
|
||||||
|
[toJid.toString()],
|
||||||
|
toJid.toString(),
|
||||||
|
newSessions,
|
||||||
|
);
|
||||||
|
|
||||||
|
await getAttributes().sendStanza(
|
||||||
|
Stanza.message(
|
||||||
|
to: toJid.toString(),
|
||||||
|
type: 'chat',
|
||||||
|
children: [
|
||||||
|
empty,
|
||||||
|
|
||||||
|
// Add a storage hint in case this is a message
|
||||||
|
// Taken from the example at
|
||||||
|
// https://xmpp.org/extensions/xep-0384.html#message-structure-description.
|
||||||
|
MessageProcessingHint.store.toXml(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
awaitable: false,
|
||||||
|
encrypted: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!calledFromCriticalSection) {
|
||||||
|
await _handlerExit(toJid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onOutgoingStanza(Stanza stanza, StanzaHandlerData state) async {
|
||||||
|
if (state.encrypted) {
|
||||||
|
logger.finest('Not encrypting since state.encrypted is true');
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stanza.to == null) {
|
||||||
|
// We cannot encrypt in this case.
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
final toJid = JID.fromString(stanza.to!).toBare();
|
||||||
|
if (!(await shouldEncryptStanza(toJid, stanza))) {
|
||||||
|
logger.finest('shouldEncryptStanza returned false for message to $toJid. Not encrypting.');
|
||||||
|
return state;
|
||||||
|
} else {
|
||||||
|
logger.finest('shouldEncryptStanza returned true for message to $toJid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final completer = await _handlerEntry(toJid);
|
||||||
|
if (completer != null) {
|
||||||
|
await completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newSessions = List<OmemoBundle>.empty(growable: true);
|
||||||
|
// Try to find new sessions for [toJid].
|
||||||
|
final resultToJid = await _findNewSessions(toJid, stanza.children);
|
||||||
|
if (resultToJid.isType<List<OmemoBundle>>()) {
|
||||||
|
newSessions.addAll(resultToJid.get<List<OmemoBundle>>());
|
||||||
|
} else {
|
||||||
|
if (resultToJid.isType<OmemoNotSupportedForContactException>()) {
|
||||||
|
await _handlerExit(toJid);
|
||||||
|
return state.copyWith(
|
||||||
|
cancel: true,
|
||||||
|
cancelReason: resultToJid.get<OmemoNotSupportedForContactException>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find new sessions for our own Jid.
|
||||||
|
final ownJid = getAttributes().getFullJID().toBare();
|
||||||
|
final resultOwnJid = await _findNewSessions(ownJid, stanza.children);
|
||||||
|
if (resultOwnJid.isType<List<OmemoBundle>>()) {
|
||||||
|
newSessions.addAll(resultOwnJid.get<List<OmemoBundle>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
final toEncrypt = List<XMLNode>.empty(growable: true);
|
||||||
|
final children = List<XMLNode>.empty(growable: true);
|
||||||
|
for (final child in stanza.children) {
|
||||||
|
if (!shouldEncryptElement(child)) {
|
||||||
|
children.add(child);
|
||||||
|
} else {
|
||||||
|
toEncrypt.add(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final jidsToEncryptFor = <String>[JID.fromString(stanza.to!).toBare().toString()];
|
||||||
|
// Prevent encrypting to self if there is only one device (ours).
|
||||||
|
if (await _hasSessionWith(ownJid.toString())) {
|
||||||
|
jidsToEncryptFor.add(ownJid.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.finest('Encrypting stanza');
|
||||||
|
final encrypted = await _encryptChildren(
|
||||||
|
toEncrypt,
|
||||||
|
jidsToEncryptFor,
|
||||||
|
stanza.to!,
|
||||||
|
newSessions,
|
||||||
|
);
|
||||||
|
logger.finest('Encryption done');
|
||||||
|
|
||||||
|
children.add(encrypted);
|
||||||
|
|
||||||
|
// Only add EME when sending a message
|
||||||
|
if (stanza.tag == 'message') {
|
||||||
|
children.add(buildEmeElement(ExplicitEncryptionType.omemo2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a storage hint in case this is a message
|
||||||
|
// Taken from the example at
|
||||||
|
// https://xmpp.org/extensions/xep-0384.html#message-structure-description.
|
||||||
|
if (stanza.tag == 'message') {
|
||||||
|
children.add(MessageProcessingHint.store.toXml());
|
||||||
|
}
|
||||||
|
|
||||||
|
await _handlerExit(toJid);
|
||||||
|
return state.copyWith(
|
||||||
|
stanza: state.stanza.copyWith(
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
encrypted: true,
|
||||||
|
);
|
||||||
|
} catch (ex) {
|
||||||
|
logger.severe('Encryption failed! $ex');
|
||||||
|
await _handlerExit(toJid);
|
||||||
|
return state.copyWith(
|
||||||
|
cancel: true,
|
||||||
|
cancelReason: EncryptionFailedException(),
|
||||||
|
other: {
|
||||||
|
...state.other,
|
||||||
|
'encryption_error': ex,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This function returns true if the encryption scheme should ignore unacked ratchets
|
||||||
|
/// and don't try to build a new ratchet even though there are unacked ones.
|
||||||
|
/// The current logic is that chat states with no body ignore the "ack" state of the
|
||||||
|
/// ratchets.
|
||||||
|
///
|
||||||
|
/// This function may be overriden. By default, the ack status of the ratchet is ignored
|
||||||
|
/// if we're sending a message containing chatstates or chat markers and the message does
|
||||||
|
/// not contain a <body /> element.
|
||||||
|
@visibleForOverriding
|
||||||
|
bool shouldIgnoreUnackedRatchets(List<XMLNode> children) {
|
||||||
|
return listContains(
|
||||||
|
children,
|
||||||
|
(XMLNode child) {
|
||||||
|
return child.attributes['xmlns'] == chatStateXmlns || child.attributes['xmlns'] == chatMarkersXmlns;
|
||||||
|
},
|
||||||
|
) && !listContains(
|
||||||
|
children,
|
||||||
|
(XMLNode child) => child.tag == 'body',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This function is called whenever a message is to be encrypted. If it returns true,
|
||||||
|
/// then the message will be encrypted. If it returns false, the message won't be
|
||||||
|
/// encrypted.
|
||||||
|
@visibleForOverriding
|
||||||
|
Future<bool> shouldEncryptStanza(JID toJid, Stanza stanza);
|
||||||
|
|
||||||
|
/// Wrapper function that attempts to enter the encryption/decryption critical section.
|
||||||
|
/// In case the critical section could be entered, null is returned. If not, then a
|
||||||
|
/// Completer is returned whose future will resolve once the critical section can be
|
||||||
|
/// entered.
|
||||||
|
Future<Completer<void>?> _handlerEntry(JID fromJid) async {
|
||||||
|
return _handlerLock.synchronized(() {
|
||||||
|
if (_handlerFutures.containsKey(fromJid)) {
|
||||||
|
final c = Completer<void>();
|
||||||
|
_handlerFutures[fromJid]!.addLast(c);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
_handlerFutures[fromJid] = Queue();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper function that exits the critical section.
|
||||||
|
Future<void> _handlerExit(JID fromJid) async {
|
||||||
|
await _handlerLock.synchronized(() {
|
||||||
|
if (_handlerFutures.containsKey(fromJid)) {
|
||||||
|
if (_handlerFutures[fromJid]!.isEmpty) {
|
||||||
|
_handlerFutures.remove(fromJid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_handlerFutures[fromJid]!.removeFirst().complete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onIncomingStanza(Stanza stanza, StanzaHandlerData state) async {
|
||||||
|
final encrypted = stanza.firstTag('encrypted', xmlns: omemoXmlns);
|
||||||
|
if (encrypted == null) return state;
|
||||||
|
if (stanza.from == null) return state;
|
||||||
|
|
||||||
|
final fromJid = JID.fromString(stanza.from!).toBare();
|
||||||
|
final completer = await _handlerEntry(fromJid);
|
||||||
|
if (completer != null) {
|
||||||
|
await completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
final header = encrypted.firstTag('header')!;
|
||||||
|
final payloadElement = encrypted.firstTag('payload');
|
||||||
|
final keys = List<EncryptedKey>.empty(growable: true);
|
||||||
|
for (final keysElement in header.findTags('keys')) {
|
||||||
|
final jid = keysElement.attributes['jid']! as String;
|
||||||
|
for (final key in keysElement.findTags('key')) {
|
||||||
|
keys.add(
|
||||||
|
EncryptedKey(
|
||||||
|
jid,
|
||||||
|
int.parse(key.attributes['rid']! as String),
|
||||||
|
key.innerText(),
|
||||||
|
key.attributes['kex'] == 'true',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final ourJid = getAttributes().getFullJID();
|
||||||
|
final sid = int.parse(header.attributes['sid']! as String);
|
||||||
|
|
||||||
|
// Ensure that if we receive a message from a device that we don't know about, we
|
||||||
|
// ensure that _deviceMap is up-to-date.
|
||||||
|
final devices = _deviceMap[fromJid] ?? <int>[];
|
||||||
|
if (!devices.contains(sid)) {
|
||||||
|
await getDeviceList(fromJid);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? decrypted;
|
||||||
|
try {
|
||||||
|
decrypted = await _decryptMessage(
|
||||||
|
payloadElement != null ? base64.decode(payloadElement.innerText()) : null,
|
||||||
|
fromJid.toString(),
|
||||||
|
sid,
|
||||||
|
keys,
|
||||||
|
state.delayedDelivery?.timestamp.millisecondsSinceEpoch ?? DateTime.now().millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
} catch (ex) {
|
||||||
|
logger.warning('Error occurred during message decryption: $ex');
|
||||||
|
|
||||||
|
await _handlerExit(fromJid);
|
||||||
|
return state.copyWith(
|
||||||
|
other: {
|
||||||
|
...state.other,
|
||||||
|
'encryption_error': ex,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final isAcked = await _isRatchetAcknowledged(fromJid.toString(), sid);
|
||||||
|
if (!isAcked) {
|
||||||
|
// Unacked ratchet decrypted this message
|
||||||
|
if (decrypted != null) {
|
||||||
|
// The message is not empty, i.e. contains content
|
||||||
|
logger.finest('Received non-empty OMEMO encrypted message for unacked ratchet. Acking with empty OMEMO message.');
|
||||||
|
|
||||||
|
await _ackRatchet(fromJid.toString(), sid);
|
||||||
|
await sendEmptyMessage(fromJid, calledFromCriticalSection: true);
|
||||||
|
|
||||||
|
final envelope = XMLNode.fromString(decrypted);
|
||||||
|
final children = stanza.children.where(
|
||||||
|
(child) => child.tag != 'encrypted' || child.attributes['xmlns'] != omemoXmlns,
|
||||||
|
).toList()
|
||||||
|
..addAll(envelope.firstTag('content')!.children);
|
||||||
|
|
||||||
|
final other = Map<String, dynamic>.from(state.other);
|
||||||
|
if (!checkAffixElements(envelope, stanza.from!, ourJid)) {
|
||||||
|
other['encryption_error'] = InvalidAffixElementsException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _handlerExit(fromJid);
|
||||||
|
return state.copyWith(
|
||||||
|
encrypted: true,
|
||||||
|
stanza: Stanza(
|
||||||
|
to: stanza.to,
|
||||||
|
from: stanza.from,
|
||||||
|
id: stanza.id,
|
||||||
|
type: stanza.type,
|
||||||
|
children: children,
|
||||||
|
tag: stanza.tag,
|
||||||
|
attributes: Map<String, String>.from(stanza.attributes),
|
||||||
|
),
|
||||||
|
other: other,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info('Received empty OMEMO message for unacked ratchet. Marking $fromJid:$sid as acked');
|
||||||
|
await _ackRatchet(fromJid.toString(), sid);
|
||||||
|
|
||||||
|
final ownId = await (await getSessionManager()).getDeviceId();
|
||||||
|
final kex = keys.any((key) => key.kex && key.rid == ownId);
|
||||||
|
if (kex) {
|
||||||
|
logger.info('Empty OMEMO message contained a kex. Answering.');
|
||||||
|
await sendEmptyMessage(fromJid, calledFromCriticalSection: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _handlerExit(fromJid);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// The ratchet that decrypted the message was acked
|
||||||
|
if (decrypted != null) {
|
||||||
|
final envelope = XMLNode.fromString(decrypted);
|
||||||
|
|
||||||
|
final children = stanza.children.where(
|
||||||
|
(child) => child.tag != 'encrypted' || child.attributes['xmlns'] != omemoXmlns,
|
||||||
|
).toList()
|
||||||
|
..addAll(envelope.firstTag('content')!.children);
|
||||||
|
|
||||||
|
final other = Map<String, dynamic>.from(state.other);
|
||||||
|
if (!checkAffixElements(envelope, stanza.from!, ourJid)) {
|
||||||
|
other['encryption_error'] = InvalidAffixElementsException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await _handlerExit(fromJid);
|
||||||
|
return state.copyWith(
|
||||||
|
encrypted: true,
|
||||||
|
stanza: Stanza(
|
||||||
|
to: stanza.to,
|
||||||
|
from: stanza.from,
|
||||||
|
id: stanza.id,
|
||||||
|
type: stanza.type,
|
||||||
|
children: children,
|
||||||
|
tag: stanza.tag,
|
||||||
|
attributes: Map<String, String>.from(stanza.attributes),
|
||||||
|
),
|
||||||
|
other: other,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info('Received empty OMEMO message on acked ratchet. Doing nothing');
|
||||||
|
await _handlerExit(fromJid);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience function that attempts to retrieve the raw XML payload from the
|
||||||
|
/// device list PubSub node.
|
||||||
|
///
|
||||||
|
/// On success, returns the XML data. On failure, returns an OmemoError.
|
||||||
|
Future<Result<OmemoError, XMLNode>> _retrieveDeviceListPayload(JID jid) async {
|
||||||
|
final pm = getAttributes().getManagerById<PubSubManager>(pubsubManager)!;
|
||||||
|
final result = await pm.getItems(jid.toBare().toString(), omemoDevicesXmlns);
|
||||||
|
if (result.isType<PubSubError>()) return Result(UnknownOmemoError());
|
||||||
|
return Result(result.get<List<PubSubItem>>().first.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves the OMEMO device list from [jid].
|
||||||
|
Future<Result<OmemoError, List<int>>> getDeviceList(JID jid) async {
|
||||||
|
if (_deviceMap.containsKey(jid)) return Result(_deviceMap[jid]);
|
||||||
|
|
||||||
|
final itemsRaw = await _retrieveDeviceListPayload(jid);
|
||||||
|
if (itemsRaw.isType<OmemoError>()) return Result(UnknownOmemoError());
|
||||||
|
|
||||||
|
final ids = itemsRaw.get<XMLNode>().children
|
||||||
|
.map((child) => int.parse(child.attributes['id']! as String))
|
||||||
|
.toList();
|
||||||
|
_deviceMap[jid] = ids;
|
||||||
|
return Result(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve all device bundles for the JID [jid].
|
||||||
|
///
|
||||||
|
/// On success, returns a list of devices. On failure, returns am OmemoError.
|
||||||
|
Future<Result<OmemoError, List<OmemoBundle>>> retrieveDeviceBundles(JID jid) async {
|
||||||
|
// TODO(Unknown): Should we query the device list first?
|
||||||
|
final pm = getAttributes().getManagerById<PubSubManager>(pubsubManager)!;
|
||||||
|
final bundlesRaw = await pm.getItems(jid.toString(), omemoBundlesXmlns);
|
||||||
|
if (bundlesRaw.isType<PubSubError>()) return Result(UnknownOmemoError());
|
||||||
|
|
||||||
|
final bundles = bundlesRaw.get<List<PubSubItem>>().map(
|
||||||
|
(bundle) => bundleFromXML(jid, int.parse(bundle.id), bundle.payload),
|
||||||
|
).toList();
|
||||||
|
|
||||||
|
return Result(bundles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves a bundle from entity [jid] with the device id [deviceId].
|
||||||
|
///
|
||||||
|
/// On success, returns the device bundle. On failure, returns an OmemoError.
|
||||||
|
Future<Result<OmemoError, OmemoBundle>> retrieveDeviceBundle(JID jid, int deviceId) async {
|
||||||
|
final pm = getAttributes().getManagerById<PubSubManager>(pubsubManager)!;
|
||||||
|
final bareJid = jid.toBare().toString();
|
||||||
|
final item = await pm.getItem(bareJid, omemoBundlesXmlns, '$deviceId');
|
||||||
|
if (item.isType<PubSubError>()) return Result(UnknownOmemoError());
|
||||||
|
|
||||||
|
return Result(bundleFromXML(jid, deviceId, item.get<PubSubItem>().payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to publish a device bundle to the device list and device bundle PubSub
|
||||||
|
/// nodes.
|
||||||
|
///
|
||||||
|
/// On success, returns true. On failure, returns an OmemoError.
|
||||||
|
Future<Result<OmemoError, bool>> publishBundle(OmemoBundle bundle) async {
|
||||||
|
final attrs = getAttributes();
|
||||||
|
final pm = attrs.getManagerById<PubSubManager>(pubsubManager)!;
|
||||||
|
final bareJid = attrs.getFullJID().toBare();
|
||||||
|
|
||||||
|
XMLNode? deviceList;
|
||||||
|
final deviceListRaw = await _retrieveDeviceListPayload(bareJid);
|
||||||
|
if (!deviceListRaw.isType<OmemoError>()) {
|
||||||
|
deviceList = deviceListRaw.get<XMLNode>();
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceList ??= XMLNode.xmlns(
|
||||||
|
tag: 'devices',
|
||||||
|
xmlns: omemoDevicesXmlns,
|
||||||
|
);
|
||||||
|
|
||||||
|
final ids = deviceList.children
|
||||||
|
.map((child) => int.parse(child.attributes['id']! as String));
|
||||||
|
|
||||||
|
if (!ids.contains(bundle.id)) {
|
||||||
|
// Only update the device list if the device Id is not there
|
||||||
|
final newDeviceList = XMLNode.xmlns(
|
||||||
|
tag: 'devices',
|
||||||
|
xmlns: omemoDevicesXmlns,
|
||||||
|
children: [
|
||||||
|
...deviceList.children,
|
||||||
|
XMLNode(
|
||||||
|
tag: 'device',
|
||||||
|
attributes: <String, String>{
|
||||||
|
'id': '${bundle.id}',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final deviceListPublish = await pm.publish(
|
||||||
|
bareJid.toString(),
|
||||||
|
omemoDevicesXmlns,
|
||||||
|
newDeviceList,
|
||||||
|
id: 'current',
|
||||||
|
options: const PubSubPublishOptions(
|
||||||
|
accessModel: 'open',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (deviceListPublish.isType<PubSubError>()) return const Result(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
final deviceBundlePublish = await pm.publish(
|
||||||
|
bareJid.toString(),
|
||||||
|
omemoBundlesXmlns,
|
||||||
|
bundleToXML(bundle),
|
||||||
|
id: '${bundle.id}',
|
||||||
|
options: const PubSubPublishOptions(
|
||||||
|
accessModel: 'open',
|
||||||
|
maxItems: 'max',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Result(deviceBundlePublish.isType<PubSubError>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribes to the device list PubSub node of [jid].
|
||||||
|
Future<void> subscribeToDeviceList(JID jid) async {
|
||||||
|
final pm = getAttributes().getManagerById<PubSubManager>(pubsubManager)!;
|
||||||
|
final result = await pm.subscribe(jid.toString(), omemoDevicesXmlns);
|
||||||
|
|
||||||
|
if (!result.isType<PubSubError>()) {
|
||||||
|
_subscriptionMap[jid] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to find out if [jid] supports omemo:2.
|
||||||
|
///
|
||||||
|
/// On success, returns whether [jid] has published a device list and device bundles.
|
||||||
|
/// On failure, returns an OmemoError.
|
||||||
|
Future<Result<OmemoError, bool>> supportsOmemo(JID jid) async {
|
||||||
|
final dm = getAttributes().getManagerById<DiscoManager>(discoManager)!;
|
||||||
|
final items = await dm.discoItemsQuery(jid.toBare().toString());
|
||||||
|
|
||||||
|
if (items.isType<DiscoError>()) return Result(UnknownOmemoError());
|
||||||
|
|
||||||
|
final nodes = items.get<List<DiscoItem>>();
|
||||||
|
final result = nodes.any((item) => item.node == omemoDevicesXmlns) && nodes.any((item) => item.node == omemoBundlesXmlns);
|
||||||
|
return Result(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to delete a device with device id [deviceId] from the device bundles node
|
||||||
|
/// and then the device list node. This allows a device that was accidentally removed
|
||||||
|
/// to republish without any race conditions.
|
||||||
|
/// Note that this does not delete a possibly existent ratchet session.
|
||||||
|
///
|
||||||
|
/// On success, returns true. On failure, returns an OmemoError.
|
||||||
|
Future<Result<OmemoError, bool>> deleteDevice(int deviceId) async {
|
||||||
|
final pm = getAttributes().getManagerById<PubSubManager>(pubsubManager)!;
|
||||||
|
final jid = getAttributes().getFullJID().toBare();
|
||||||
|
|
||||||
|
final bundleResult = await pm.retract(jid, omemoBundlesXmlns, '$deviceId');
|
||||||
|
if (bundleResult.isType<PubSubError>()) {
|
||||||
|
// TODO(Unknown): Be more specific
|
||||||
|
return Result(UnknownOmemoError());
|
||||||
|
}
|
||||||
|
|
||||||
|
final deviceListResult = await _retrieveDeviceListPayload(jid);
|
||||||
|
if (deviceListResult.isType<OmemoError>()) {
|
||||||
|
return Result(bundleResult.get<OmemoError>());
|
||||||
|
}
|
||||||
|
|
||||||
|
final payload = deviceListResult.get<XMLNode>();
|
||||||
|
final newPayload = XMLNode.xmlns(
|
||||||
|
tag: 'devices',
|
||||||
|
xmlns: omemoDevicesXmlns,
|
||||||
|
children: payload.children
|
||||||
|
.where((child) => child.attributes['id'] != '$deviceId')
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
final publishResult = await pm.publish(
|
||||||
|
jid.toString(),
|
||||||
|
omemoDevicesXmlns,
|
||||||
|
newPayload,
|
||||||
|
id: 'current',
|
||||||
|
options: const PubSubPublishOptions(
|
||||||
|
accessModel: 'open',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (publishResult.isType<PubSubError>()) return Result(UnknownOmemoError());
|
||||||
|
|
||||||
|
return const Result(true);
|
||||||
|
}
|
||||||
|
}
|
99
moxxmpp/lib/src/xeps/xep_0385.dart
Normal file
99
moxxmpp/lib/src/xeps/xep_0385.dart
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/staging/extensible_file_thumbnails.dart';
|
||||||
|
|
||||||
|
class StatelessMediaSharingData {
|
||||||
|
|
||||||
|
const StatelessMediaSharingData({ required this.mediaType, required this.size, required this.description, required this.hashes, required this.url, required this.thumbnails });
|
||||||
|
final String mediaType;
|
||||||
|
final int size;
|
||||||
|
final String description;
|
||||||
|
final Map<String, String> hashes; // algo -> hash value
|
||||||
|
final List<Thumbnail> thumbnails;
|
||||||
|
|
||||||
|
final String url;
|
||||||
|
}
|
||||||
|
|
||||||
|
StatelessMediaSharingData parseSIMSElement(XMLNode node) {
|
||||||
|
assert(node.attributes['xmlns'] == simsXmlns, 'Invalid element xmlns');
|
||||||
|
assert(node.tag == 'media-sharing', 'Invalid element name');
|
||||||
|
|
||||||
|
final file = node.firstTag('file', xmlns: jingleFileTransferXmlns)!;
|
||||||
|
final hashes = <String, String>{};
|
||||||
|
for (final i in file.findTags('hash', xmlns: hashXmlns)) {
|
||||||
|
hashes[i.attributes['algo']! as String] = i.innerText();
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = '';
|
||||||
|
final references = file.firstTag('sources')!.findTags('reference', xmlns: referenceXmlns);
|
||||||
|
for (final i in references) {
|
||||||
|
if (i.attributes['type'] != 'data') continue;
|
||||||
|
|
||||||
|
final uri = i.attributes['uri']! as String;
|
||||||
|
if (!uri.startsWith('https://')) continue;
|
||||||
|
|
||||||
|
url = uri;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final thumbnails = List<Thumbnail>.empty(growable: true);
|
||||||
|
for (final child in file.children) {
|
||||||
|
// TODO(Unknown): Handle other thumbnails
|
||||||
|
if (child.tag == 'file-thumbnail' && child.attributes['xmlns'] == fileThumbnailsXmlns) {
|
||||||
|
final thumb = parseFileThumbnailElement(child);
|
||||||
|
if (thumb != null) {
|
||||||
|
thumbnails.add(thumb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return StatelessMediaSharingData(
|
||||||
|
mediaType: file.firstTag('media-type')!.innerText(),
|
||||||
|
size: int.parse(file.firstTag('size')!.innerText()),
|
||||||
|
description: file.firstTag('description')!.innerText(),
|
||||||
|
url: url,
|
||||||
|
hashes: hashes,
|
||||||
|
thumbnails: thumbnails,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SIMSManager extends XmppManagerBase {
|
||||||
|
@override
|
||||||
|
String getName() => 'SIMSManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => simsManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> getDiscoFeatures() => [ simsXmlns ];
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
callback: _onMessage,
|
||||||
|
tagName: 'reference',
|
||||||
|
tagXmlns: referenceXmlns,
|
||||||
|
// Before the message handler
|
||||||
|
priority: -99,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onMessage(Stanza message, StanzaHandlerData state) async {
|
||||||
|
final references = message.findTags('reference', xmlns: referenceXmlns);
|
||||||
|
for (final ref in references) {
|
||||||
|
final sims = ref.firstTag('media-sharing', xmlns: simsXmlns);
|
||||||
|
if (sims != null) return state.copyWith(sims: parseSIMSElement(sims));
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
32
moxxmpp/lib/src/xeps/xep_0414.dart
Normal file
32
moxxmpp/lib/src/xeps/xep_0414.dart
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import 'package:cryptography/cryptography.dart';
|
||||||
|
|
||||||
|
class InvalidHashAlgorithmException implements Exception {
|
||||||
|
|
||||||
|
InvalidHashAlgorithmException(this.name);
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
String errMsg() => 'Invalid hash algorithm: $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the hash algorithm specified by its name, according to XEP-0414.
|
||||||
|
HashAlgorithm? getHashByName(String name) {
|
||||||
|
switch (name) {
|
||||||
|
case 'sha-1': return Sha1();
|
||||||
|
case 'sha-256': return Sha256();
|
||||||
|
case 'sha-512': return Sha512();
|
||||||
|
// NOTE: cryptography provides an implementation of blake2b, however,
|
||||||
|
// I have no idea what it's output length is and you cannot set
|
||||||
|
// one. => New dependency
|
||||||
|
// TODO(Unknown): Implement
|
||||||
|
//case "blake2b-256": ;
|
||||||
|
// hashLengthInBytes == 64 => 512?
|
||||||
|
case 'blake2b-512': Blake2b();
|
||||||
|
// NOTE: cryptography does not provide SHA3 hashes => New dependency
|
||||||
|
// TODO(Unknown): Implement
|
||||||
|
//case "sha3-256": ;
|
||||||
|
// TODO(Unknown): Implement
|
||||||
|
//case "sha3-512": ;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw InvalidHashAlgorithmException(name);
|
||||||
|
}
|
108
moxxmpp/lib/src/xeps/xep_0446.dart
Normal file
108
moxxmpp/lib/src/xeps/xep_0446.dart
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/staging/extensible_file_thumbnails.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0300.dart';
|
||||||
|
|
||||||
|
class FileMetadataData {
|
||||||
|
|
||||||
|
const FileMetadataData({
|
||||||
|
this.mediaType,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.desc,
|
||||||
|
this.length,
|
||||||
|
this.name,
|
||||||
|
this.size,
|
||||||
|
required this.thumbnails,
|
||||||
|
Map<String, String>? hashes,
|
||||||
|
}) : hashes = hashes ?? const {};
|
||||||
|
|
||||||
|
/// Parse [node] as a FileMetadataData element.
|
||||||
|
factory FileMetadataData.fromXML(XMLNode node) {
|
||||||
|
assert(node.attributes['xmlns'] == fileMetadataXmlns, 'Invalid element xmlns');
|
||||||
|
assert(node.tag == 'file', 'Invalid element anme');
|
||||||
|
|
||||||
|
final lengthElement = node.firstTag('length');
|
||||||
|
final length = lengthElement != null ? int.parse(lengthElement.innerText()) : null;
|
||||||
|
final sizeElement = node.firstTag('size');
|
||||||
|
final size = sizeElement != null ? int.parse(sizeElement.innerText()) : null;
|
||||||
|
|
||||||
|
final hashes = <String, String>{};
|
||||||
|
for (final e in node.findTags('hash')) {
|
||||||
|
hashes[e.attributes['algo']! as String] = e.innerText();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnails
|
||||||
|
final thumbnails = List<Thumbnail>.empty(growable: true);
|
||||||
|
for (final i in node.findTags('file-thumbnail')) {
|
||||||
|
final thumbnail = parseFileThumbnailElement(i);
|
||||||
|
if (thumbnail != null) {
|
||||||
|
thumbnails.add(thumbnail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length and height
|
||||||
|
final widthString = node.firstTag('length');
|
||||||
|
final heightString = node.firstTag('height');
|
||||||
|
int? width;
|
||||||
|
int? height;
|
||||||
|
if (widthString != null) {
|
||||||
|
width = int.parse(widthString.innerText());
|
||||||
|
}
|
||||||
|
if (heightString != null) {
|
||||||
|
height = int.parse(heightString.innerText());
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileMetadataData(
|
||||||
|
mediaType: node.firstTag('media-type')?.innerText(),
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
desc: node.firstTag('desc')?.innerText(),
|
||||||
|
hashes: hashes,
|
||||||
|
length: length,
|
||||||
|
name: node.firstTag('name')?.innerText(),
|
||||||
|
size: size,
|
||||||
|
thumbnails: thumbnails,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String? mediaType;
|
||||||
|
final int? width;
|
||||||
|
final int? height;
|
||||||
|
final List<Thumbnail> thumbnails;
|
||||||
|
final String? desc;
|
||||||
|
final Map<String, String> hashes;
|
||||||
|
final int? length;
|
||||||
|
final String? name;
|
||||||
|
final int? size;
|
||||||
|
|
||||||
|
XMLNode toXML() {
|
||||||
|
final node = XMLNode.xmlns(
|
||||||
|
tag: 'file',
|
||||||
|
xmlns: fileMetadataXmlns,
|
||||||
|
children: List.empty(growable: true),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mediaType != null) node.addChild(XMLNode(tag: 'media-type', text: mediaType));
|
||||||
|
if (width != null) node.addChild(XMLNode(tag: 'width', text: '$width'));
|
||||||
|
if (height != null) node.addChild(XMLNode(tag: 'height', text: '$height'));
|
||||||
|
if (desc != null) node.addChild(XMLNode(tag: 'desc', text: desc));
|
||||||
|
if (length != null) node.addChild(XMLNode(tag: 'length', text: length.toString()));
|
||||||
|
if (name != null) node.addChild(XMLNode(tag: 'name', text: name));
|
||||||
|
if (size != null) node.addChild(XMLNode(tag: 'size', text: size.toString()));
|
||||||
|
|
||||||
|
for (final hash in hashes.entries) {
|
||||||
|
node.addChild(
|
||||||
|
constructHashElement(hash.key, hash.value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final thumbnail in thumbnails) {
|
||||||
|
node.addChild(
|
||||||
|
constructFileThumbnailElement(thumbnail),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
}
|
126
moxxmpp/lib/src/xeps/xep_0447.dart
Normal file
126
moxxmpp/lib/src/xeps/xep_0447.dart
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0446.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0448.dart';
|
||||||
|
|
||||||
|
/// The base class for sources for StatelessFileSharing
|
||||||
|
// ignore: one_member_abstracts
|
||||||
|
abstract class StatelessFileSharingSource {
|
||||||
|
/// Turn the source into an XML element.
|
||||||
|
XMLNode toXml();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementation for url-data source elements.
|
||||||
|
class StatelessFileSharingUrlSource extends StatelessFileSharingSource {
|
||||||
|
|
||||||
|
StatelessFileSharingUrlSource(this.url);
|
||||||
|
|
||||||
|
factory StatelessFileSharingUrlSource.fromXml(XMLNode element) {
|
||||||
|
assert(element.attributes['xmlns'] == urlDataXmlns, 'Element has the wrong xmlns');
|
||||||
|
|
||||||
|
return StatelessFileSharingUrlSource(element.attributes['target']! as String);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
@override
|
||||||
|
XMLNode toXml() {
|
||||||
|
return XMLNode.xmlns(
|
||||||
|
tag: 'url-data',
|
||||||
|
xmlns: urlDataXmlns,
|
||||||
|
attributes: <String, String>{
|
||||||
|
'target': url,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatelessFileSharingData {
|
||||||
|
|
||||||
|
const StatelessFileSharingData(this.metadata, this.sources);
|
||||||
|
|
||||||
|
/// Parse [node] as a StatelessFileSharingData element.
|
||||||
|
factory StatelessFileSharingData.fromXML(XMLNode node) {
|
||||||
|
assert(node.attributes['xmlns'] == sfsXmlns, 'Invalid element xmlns');
|
||||||
|
assert(node.tag == 'file-sharing', 'Invalid element name');
|
||||||
|
|
||||||
|
final sources = List<StatelessFileSharingSource>.empty(growable: true);
|
||||||
|
|
||||||
|
final sourcesElement = node.firstTag('sources')!;
|
||||||
|
for (final source in sourcesElement.children) {
|
||||||
|
if (source.attributes['xmlns'] == urlDataXmlns) {
|
||||||
|
sources.add(StatelessFileSharingUrlSource.fromXml(source));
|
||||||
|
} else if (source.attributes['xmlns'] == sfsEncryptionXmlns) {
|
||||||
|
sources.add(StatelessFileSharingEncryptedSource.fromXml(source));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return StatelessFileSharingData(
|
||||||
|
FileMetadataData.fromXML(node.firstTag('file')!),
|
||||||
|
sources,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final FileMetadataData metadata;
|
||||||
|
final List<StatelessFileSharingSource> sources;
|
||||||
|
|
||||||
|
XMLNode toXML() {
|
||||||
|
return XMLNode.xmlns(
|
||||||
|
tag: 'file-sharing',
|
||||||
|
xmlns: sfsXmlns,
|
||||||
|
children: [
|
||||||
|
metadata.toXML(),
|
||||||
|
XMLNode(
|
||||||
|
tag: 'sources',
|
||||||
|
children: sources
|
||||||
|
.map((source) => source.toXml())
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
StatelessFileSharingUrlSource? getFirstUrlSource() {
|
||||||
|
return firstWhereOrNull(
|
||||||
|
sources,
|
||||||
|
(StatelessFileSharingSource source) => source is StatelessFileSharingUrlSource,
|
||||||
|
) as StatelessFileSharingUrlSource?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SFSManager extends XmppManagerBase {
|
||||||
|
@override
|
||||||
|
String getName() => 'SFSManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => sfsManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
tagName: 'file-sharing',
|
||||||
|
tagXmlns: sfsXmlns,
|
||||||
|
callback: _onMessage,
|
||||||
|
// Before the message handler
|
||||||
|
priority: -99,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onMessage(Stanza message, StanzaHandlerData state) async {
|
||||||
|
final sfs = message.firstTag('file-sharing', xmlns: sfsXmlns)!;
|
||||||
|
|
||||||
|
return state.copyWith(
|
||||||
|
sfs: StatelessFileSharingData.fromXML(sfs),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
103
moxxmpp/lib/src/xeps/xep_0448.dart
Normal file
103
moxxmpp/lib/src/xeps/xep_0448.dart
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stringxml.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0300.dart';
|
||||||
|
import 'package:moxxmpp/src/xeps/xep_0447.dart';
|
||||||
|
|
||||||
|
enum SFSEncryptionType {
|
||||||
|
aes128GcmNoPadding,
|
||||||
|
aes256GcmNoPadding,
|
||||||
|
aes256CbcPkcs7,
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SFSEncryptionTypeNamespaceExtension on SFSEncryptionType {
|
||||||
|
String toNamespace() {
|
||||||
|
switch (this) {
|
||||||
|
case SFSEncryptionType.aes128GcmNoPadding:
|
||||||
|
return sfsEncryptionAes128GcmNoPaddingXmlns;
|
||||||
|
case SFSEncryptionType.aes256GcmNoPadding:
|
||||||
|
return sfsEncryptionAes256GcmNoPaddingXmlns;
|
||||||
|
case SFSEncryptionType.aes256CbcPkcs7:
|
||||||
|
return sfsEncryptionAes256CbcPkcs7Xmlns;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SFSEncryptionType encryptionTypeFromNamespace(String xmlns) {
|
||||||
|
switch (xmlns) {
|
||||||
|
case sfsEncryptionAes128GcmNoPaddingXmlns:
|
||||||
|
return SFSEncryptionType.aes128GcmNoPadding;
|
||||||
|
case sfsEncryptionAes256GcmNoPaddingXmlns:
|
||||||
|
return SFSEncryptionType.aes256GcmNoPadding;
|
||||||
|
case sfsEncryptionAes256CbcPkcs7Xmlns:
|
||||||
|
return SFSEncryptionType.aes256CbcPkcs7;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception();
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatelessFileSharingEncryptedSource extends StatelessFileSharingSource {
|
||||||
|
|
||||||
|
StatelessFileSharingEncryptedSource(this.encryption, this.key, this.iv, this.hashes, this.source);
|
||||||
|
factory StatelessFileSharingEncryptedSource.fromXml(XMLNode element) {
|
||||||
|
assert(element.attributes['xmlns'] == sfsEncryptionXmlns, 'Element has invalid xmlns');
|
||||||
|
|
||||||
|
final key = base64Decode(element.firstTag('key')!.text!);
|
||||||
|
final iv = base64Decode(element.firstTag('iv')!.text!);
|
||||||
|
final sources = element.firstTag('sources', xmlns: sfsXmlns)!.children;
|
||||||
|
|
||||||
|
// Find the first URL source
|
||||||
|
final source = firstWhereOrNull(
|
||||||
|
sources,
|
||||||
|
(XMLNode child) => child.tag == 'url-data' && child.attributes['xmlns'] == urlDataXmlns,
|
||||||
|
)!;
|
||||||
|
|
||||||
|
// Find hashes
|
||||||
|
final hashes = <String, String>{};
|
||||||
|
for (final hash in element.findTags('hash', xmlns: hashXmlns)) {
|
||||||
|
hashes[hash.attributes['algo']! as String] = hash.text!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return StatelessFileSharingEncryptedSource(
|
||||||
|
encryptionTypeFromNamespace(element.attributes['cipher']! as String),
|
||||||
|
key,
|
||||||
|
iv,
|
||||||
|
hashes,
|
||||||
|
StatelessFileSharingUrlSource.fromXml(source),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<int> key;
|
||||||
|
final List<int> iv;
|
||||||
|
final SFSEncryptionType encryption;
|
||||||
|
final Map<String, String> hashes;
|
||||||
|
final StatelessFileSharingUrlSource source;
|
||||||
|
|
||||||
|
@override
|
||||||
|
XMLNode toXml() {
|
||||||
|
return XMLNode.xmlns(
|
||||||
|
tag: 'encrypted',
|
||||||
|
xmlns: sfsEncryptionXmlns,
|
||||||
|
attributes: <String, String>{
|
||||||
|
'cipher': encryption.toNamespace(),
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
XMLNode(
|
||||||
|
tag: 'key',
|
||||||
|
text: base64Encode(key),
|
||||||
|
),
|
||||||
|
XMLNode(
|
||||||
|
tag: 'iv',
|
||||||
|
text: base64Encode(iv),
|
||||||
|
),
|
||||||
|
...hashes.entries.map((hash) => constructHashElement(hash.key, hash.value)),
|
||||||
|
XMLNode.xmlns(
|
||||||
|
tag: 'sources',
|
||||||
|
xmlns: sfsXmlns,
|
||||||
|
children: [source.toXml()],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
66
moxxmpp/lib/src/xeps/xep_0461.dart
Normal file
66
moxxmpp/lib/src/xeps/xep_0461.dart
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import 'package:moxxmpp/src/managers/base.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/data.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/handlers.dart';
|
||||||
|
import 'package:moxxmpp/src/managers/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/namespaces.dart';
|
||||||
|
import 'package:moxxmpp/src/stanza.dart';
|
||||||
|
|
||||||
|
class ReplyData {
|
||||||
|
|
||||||
|
const ReplyData({
|
||||||
|
required this.to,
|
||||||
|
required this.id,
|
||||||
|
this.start,
|
||||||
|
this.end,
|
||||||
|
});
|
||||||
|
final String to;
|
||||||
|
final String id;
|
||||||
|
final int? start;
|
||||||
|
final int? end;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageRepliesManager extends XmppManagerBase {
|
||||||
|
@override
|
||||||
|
String getName() => 'MessageRepliesManager';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String getId() => messageRepliesManager;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<StanzaHandler> getIncomingStanzaHandlers() => [
|
||||||
|
StanzaHandler(
|
||||||
|
stanzaTag: 'message',
|
||||||
|
tagName: 'reply',
|
||||||
|
tagXmlns: replyXmlns,
|
||||||
|
callback: _onMessage,
|
||||||
|
// Before the message handler
|
||||||
|
priority: -99,
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isSupported() async => true;
|
||||||
|
|
||||||
|
Future<StanzaHandlerData> _onMessage(Stanza stanza, StanzaHandlerData state) async {
|
||||||
|
final reply = stanza.firstTag('reply', xmlns: replyXmlns)!;
|
||||||
|
final id = reply.attributes['id']! as String;
|
||||||
|
final to = reply.attributes['to']! as String;
|
||||||
|
int? start;
|
||||||
|
int? end;
|
||||||
|
|
||||||
|
// TODO(Unknown): Maybe extend firstTag to also look for attributes
|
||||||
|
final fallback = stanza.firstTag('fallback', xmlns: fallbackXmlns);
|
||||||
|
if (fallback != null) {
|
||||||
|
final body = fallback.firstTag('body')!;
|
||||||
|
start = int.parse(body.attributes['start']! as String);
|
||||||
|
end = int.parse(body.attributes['end']! as String);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.copyWith(reply: ReplyData(
|
||||||
|
id: id,
|
||||||
|
to: to,
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
),);
|
||||||
|
}
|
||||||
|
}
|
36
moxxmpp/pubspec.yaml
Normal file
36
moxxmpp/pubspec.yaml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
name: moxxmpp
|
||||||
|
description: A pure-Dart XMPP library
|
||||||
|
version: 0.1.0
|
||||||
|
homepage: https://codeberg.org/moxxy/moxxmpp
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: '>=2.18.0 <3.0.0'
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
cryptography: 2.0.5
|
||||||
|
hex: 0.2.0
|
||||||
|
logging: 1.0.2
|
||||||
|
moxlib:
|
||||||
|
hosted: https://git.polynom.me/api/packages/Moxxy/pub
|
||||||
|
version: 0.1.5
|
||||||
|
omemo_dart:
|
||||||
|
hosted: https://git.polynom.me/api/packages/PapaTutuWawa/pub
|
||||||
|
version: 0.3.1
|
||||||
|
random_string: 2.3.1
|
||||||
|
saslprep: 1.0.2
|
||||||
|
uuid: 3.0.5
|
||||||
|
xml: ^6.1.0
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
build_runner: ^2.1.11
|
||||||
|
freezed: ^2.1.0+1
|
||||||
|
json_serializable: ^6.3.1
|
||||||
|
meta: ^1.7.0
|
||||||
|
test: ^1.16.0
|
||||||
|
very_good_analysis: ^3.0.1
|
||||||
|
|
||||||
|
dependency_overrides:
|
||||||
|
omemo_dart:
|
||||||
|
git:
|
||||||
|
url: https://codeberg.org/PapaTutuWawa/omemo_dart.git
|
||||||
|
rev: c68471349ab1b347ec9ad54651265710842c50b7
|
16
moxxmpp/test/moxxmpp_test.dart
Normal file
16
moxxmpp/test/moxxmpp_test.dart
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('A group of tests', () {
|
||||||
|
final awesome = Awesome();
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
// Additional setup goes here.
|
||||||
|
});
|
||||||
|
|
||||||
|
test('First Test', () {
|
||||||
|
expect(awesome.isAwesome, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user