feat: Initial code

This commit is contained in:
PapaTutuWawa 2022-11-05 13:40:05 +01:00
parent 999e6c521a
commit a3ced3a6da
90 changed files with 10421 additions and 0 deletions

10
.gitignore vendored
View File

@ -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
View File

@ -0,0 +1,3 @@
## 0.1.0
- Initial version copied over from Moxxyv2

7
moxxmpp/README.md Normal file
View File

@ -0,0 +1,7 @@
# moxxmpp
A pure-Dart XMPP library written for Moxxy.
## License
See `../LICENSE`.

View 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/"

View 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
View 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';

View 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;
}
}

File diff suppressed because it is too large Load Diff

206
moxxmpp/lib/src/events.dart Normal file
View 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
View 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
View 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;
}

View 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;
}

View 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;
}
}

View 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;
}

View 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;
}

View 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);

View 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';

View File

View 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 = '&gt; ${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);
}
}

View 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';

View File

View 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';

View 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;
}

View 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();
}
}

View 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;
}

View 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;
}
}

View 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,
);
}

View 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();
}
}

View 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();
}
}

View 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
View 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();
}
}
}
}
}

View 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,
);
}
}

View 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 {}
}

View 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;
}
}
}
*/

View 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
View 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;
}
}

View File

@ -0,0 +1,6 @@
enum RoutingState {
error,
preConnection,
negotiating,
handleStanzas
}

View 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
View 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
View 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,
)
] : [],
)
],
)
],
);
}
}

View 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 ?? '';
}
}

View 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!;
}

View 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;
}

View 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;
}
}

View 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 ],
);
}

View 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,
);
}
}

View 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,
);
}

View File

@ -0,0 +1,7 @@
abstract class DiscoError {}
class UnknownDiscoError extends DiscoError {}
class InvalidResponseDiscoError extends DiscoError {}
class ErrorResponseDiscoError extends DiscoError {}

View 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 } : {},
)
],);
}

View 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;
}

View 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);
}
}

View 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);
}
}

View 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 {}

View 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();
}

View 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);
}
}

View 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(),
),
);
}
}

View 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;
}
}

View 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) ],
),
);
}
}

View 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);
}

View 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);
}
}

View 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();
}
}

View 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();
}
}

View 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,
},
);
}

View 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);
}

View 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;
}

View 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,
};

View 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();
}
}

View 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),
),
);
}
}

View 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();
}
}

View 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,
);
}

View 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;
}
}

View 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);
}
}

View 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,
);
}
}

View 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());
}
}
}

View 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,
),
);
}
}

View 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),
),
);
}
}

View 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,
),
);
}
}

View 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();
}

View File

@ -0,0 +1,9 @@
abstract class OmemoError {}
class UnknownOmemoError extends OmemoError {}
class InvalidAffixElementsException with Exception {}
class OmemoNotSupportedForContactException extends OmemoError {}
class EncryptionFailedException with Exception {}

View 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,
),
],
);
}

View 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;
}

View 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);
}
}

View 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;
}
}

View 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);
}

View 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;
}
}

View 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),
);
}
}

View 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()],
),
],
);
}
}

View 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
View 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

View 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);
});
});
}