feat: Initial code
This commit is contained in:
parent
999e6c521a
commit
a3ced3a6da
10
.gitignore
vendored
10
.gitignore
vendored
@ -1,2 +1,12 @@
|
||||
.envrc
|
||||
.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