51 Commits

Author SHA1 Message Date
c61ddeb338 chore(xep): Move FAST from staging into xep_0484.dart 2024-11-17 20:24:03 +01:00
e2515e25e4 fix(tests): Fix component integration test 2024-11-16 17:59:11 +01:00
09a849c6eb fix(xep): Fix failure with multiple SCRAM negotiators and SASL2 2024-11-16 17:57:29 +01:00
9eb94e5f48 fix(xep): Fix crash when the device list node is empty 2024-10-19 21:45:18 +02:00
db77790bf4 fix(meta): Fix version conflicts with moxxy 2024-09-29 18:46:04 +02:00
7ceee48d31 fix(core): Bump omemo_dart (and everything else) 2024-09-29 18:39:49 +02:00
941c3e4fd8 test(xep): Test SCRAM-SHA-1 with SASL2 2024-08-12 23:05:38 +02:00
365ff2f238 fix(xep): Fix replies in the context of Gajim 2024-06-16 14:56:07 +02:00
b3c8a6cd2f fix(docs): Update link to moxxmpp documentation 2024-04-27 00:22:45 +02:00
d4166d087e fix(core): An empty iq is okay with roster versioning
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-10-25 21:03:04 +02:00
ddf781daff fix(core): Remove erroneous override
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-10-05 20:55:41 +02:00
5973076b89 fix(core): Remove overrides file 2023-10-05 20:54:47 +02:00
72cb76d1f6 Merge pull request 'Various improvements and fixes' (#49) from fix/stanza-ordering into master
Reviewed-on: https://codeberg.org/moxxy/moxxmpp/pulls/49
2023-10-05 18:50:32 +00:00
be7581e841 fix(core): Remove empty file 2023-10-04 22:42:36 +02:00
8a2435e4ad fix(example): Bump moxxmpp* versions to 0.4.0 2023-10-04 22:35:00 +02:00
97f082b6f5 feat(example): Add a very simple client example 2023-10-04 22:15:59 +02:00
f287d501ab fix(example): Comform examples to the new TCPSocketWrapper constructor 2023-10-04 22:15:24 +02:00
93e9d6ca22 feat(xep): Handle a user changing their nickname 2023-10-01 20:44:47 +02:00
007cdce53d fix(xep): Fix wrong event being triggered on join 2023-10-01 13:34:13 +02:00
6d3a5e98de fix(xep): Export XEP-0045 events 2023-10-01 13:33:58 +02:00
e97d6e6517 feat(xep): Track our own affiliation and role 2023-09-29 22:45:56 +02:00
882d20dc7a feat(core): Bump moxxmpp_socket_tcp's version 2023-09-29 21:19:28 +02:00
1f712151e4 feat(xep): Ignore the ack timer if we are receiving data 2023-09-29 21:18:43 +02:00
e7922668b1 feat(core): Add a callback for raw data events 2023-09-29 21:13:57 +02:00
87866bf3f5 fix(core): Allow disabling logging of all incoming and outgoing data 2023-09-29 20:53:29 +02:00
41b789fa28 feat(core): Stop an exception in a handler to deadlock the connection 2023-09-29 20:50:03 +02:00
0a68f09fb4 fix(style): Fix style issues 2023-09-29 20:46:14 +02:00
edf1d0b257 feat(core): Replace custom class with a record type 2023-09-29 20:33:56 +02:00
59b90307c2 fix(core): Remove the negotiation lock 2023-09-29 20:29:25 +02:00
49d3c6411b fix(tests): Fix tests 2023-09-29 20:24:58 +02:00
3a94dd9634 feat(core): Log handler executions 2023-09-29 20:01:09 +02:00
fb4b4c71e2 fix(core): Remove async_queue 2023-09-29 19:59:38 +02:00
d9fbb9e102 fix(xep,core): Ensure in-order processing of incoming stanzas 2023-09-29 19:58:43 +02:00
aba90f2e90 feat(example): Print the number of users in the MUC 2023-09-27 18:58:17 +02:00
9211963390 fix(xep): Fix ending presence processing too early if containing a photo 2023-09-27 18:57:42 +02:00
c7d58c3d3f feat(core): Add logging for when a manager ends processing early 2023-09-27 18:57:13 +02:00
6dbbf08be4 feat(xep): Add an event for when someone leaves the MUC
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-09-26 23:42:49 +02:00
7ca648c478 feat(xep): Add MUC events
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-09-26 23:34:53 +02:00
814f99436b fix(xep): Do not automatically request vCards
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-09-26 14:16:41 +02:00
5bd2466c54 feat(xep): Parse the room info from the extended disco info
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-09-26 14:00:27 +02:00
14b62cef96 fix(xep): Fix crash for messages with no id 2023-09-26 13:59:51 +02:00
c3088f9046 fix(xep): Make leaving the room non-awaitable
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-09-25 21:08:24 +02:00
64b93b536e fix(xep): Add missing metadata
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-09-24 18:44:23 +02:00
c1c48d0a83 feat(xep): Provide an implementation of XEP-0392 2023-09-24 18:43:06 +02:00
4a681b9483 fix(xep): Somehow fix reconnection issues
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-09-23 22:19:06 +02:00
c504afc944 fix(xep): Fix joining a MUC making it stuck
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-09-23 14:14:23 +02:00
76a9f7be7a feat(xep): Allow adding MUCs to join later
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-09-22 21:43:56 +02:00
afa3927720 feat(xep): Rejoin groupchats on a new stream 2023-09-22 21:22:18 +02:00
5f36289f50 fix(all): Fix linter warnings 2023-09-22 20:57:05 +02:00
fbe3b90200 feat(xep): Implement ignoring the message reflection 2023-09-22 20:42:45 +02:00
d7c13abde6 feat(xep): Allow ignoring the discussion history
Also allow specifying the amount of stanzas of discussion history we
want.
2023-09-22 19:23:35 +02:00
87 changed files with 2725 additions and 494 deletions

View File

@@ -7,7 +7,7 @@ moxxmpp is a XMPP library written purely in Dart for usage in Moxxy.
This package contains the actual XMPP code that is platform-independent.
Documentation is available [here](https://moxxy.org/developers/docs/moxxmpp/).
Documentation is available [here](https://docs.moxxy.org/moxxmpp/index.html).
### [moxxmpp_socket_tcp](./packages/moxxmpp_socket_tcp)
@@ -15,6 +15,10 @@ Documentation is available [here](https://moxxy.org/developers/docs/moxxmpp/).
implements the RFC6120 connection algorithm and XEP-0368 direct TLS connections,
if a DNS implementation is given, and supports StartTLS.
### moxxmpp_color
Implementation of [XEP-0392](https://xmpp.org/extensions/xep-0392.html).
## Development
To begin, use [melos](https://github.com/invertase/melos) to bootstrap the project: `melos bootstrap`. Then, the example

View File

@@ -3,6 +3,8 @@ import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxmpp_socket_tcp/moxxmpp_socket_tcp.dart';
class TestingTCPSocketWrapper extends TCPSocketWrapper {
TestingTCPSocketWrapper() : super(true);
@override
bool onBadCertificate(dynamic certificate, String domain) {
return true;

View File

@@ -38,6 +38,7 @@ void main(List<String> args) async {
DiscoManager([]),
PubSubManager(),
MessageManager(),
StableIdManager(),
MUCManager(),
]);
await connection.registerFeatureNegotiators([
@@ -57,8 +58,33 @@ void main(List<String> args) async {
}
Logger.root.info('Connected.');
// Print received messages.
connection
.asBroadcastStream()
.where((event) => event is MessageEvent)
.listen((event) {
event as MessageEvent;
// Ignore messages with no <body />
final body = event.get<MessageBodyData>()?.body;
if (body == null) {
return;
}
print('=====> [${event.from}] $body');
});
// Join room
await connection.getManagerById<MUCManager>(mucManager)!.joinRoom(muc, nick);
final mm = connection.getManagerById<MUCManager>(mucManager)!;
await mm.joinRoom(
muc,
nick,
maxHistoryStanzas: 0,
);
final state = (await mm.getRoomState(muc))!;
print('=====> ${state.members.length} users in room');
print('=====> ${state.members.values.map((m) => m.nick).join(", ")}');
final repl = Repl(prompt: '> ');
await for (final line in repl.runAsync()) {
@@ -68,6 +94,11 @@ void main(List<String> args) async {
muc,
TypedMap<StanzaHandlerExtension>.fromList([
MessageBodyData(line),
StableIdData(
// NOTE: Don't do this. Use a UUID.
DateTime.now().millisecondsSinceEpoch.toString(),
null,
),
]),
type: 'groupchat');
}

View File

@@ -30,7 +30,7 @@ void main(List<String> args) async {
TestingReconnectionPolicy(),
AlwaysConnectedConnectivityManager(),
ClientToServerNegotiator(),
ExampleTCPSocketWrapper(parser.srvRecord),
ExampleTCPSocketWrapper(parser.srvRecord, true),
)..connectionSettings = parser.connectionSettings;
// Generate OMEMO data

View File

@@ -0,0 +1,112 @@
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxmpp_socket_tcp/moxxmpp_socket_tcp.dart';
/// The JID we want to authenticate as.
final xmppUser = JID.fromString('jane@example.com');
/// The password to authenticate with.
const xmppPass = 'secret';
/// The [xmppHost]:[xmppPort] server address to connect to.
/// In a real application, one might prefer to use [TCPSocketWrapper]
/// with a custom DNS implementation to let moxxmpp resolve the XMPP
/// server's address automatically. However, if we just provide a host
/// and a port, then [TCPSocketWrapper] will just skip the resolution and
/// immediately use the provided connection details.
const xmppHost = 'localhost';
const xmppPort = 5222;
void main(List<String> args) async {
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) {
print('${record.level.name}|${record.time}: ${record.message}');
});
// This class manages every aspect of handling the XMPP stream.
final connection = XmppConnection(
// A reconnection policy tells the connection how to handle an error
// while or after connecting to the server. The [TestingReconnectionPolicy]
// immediately triggers a reconnection. In a real implementation, one might
// prefer to use a smarter strategy, like using an exponential backoff.
TestingReconnectionPolicy(),
// A connectivity manager tells the connection when it can connect. This is to
// ensure that we're not constantly trying to reconnect because we have no
// Internet connection. [AlwaysConnectedConnectivityManager] always says that
// we're connected. In a real application, one might prefer to use a smarter
// strategy, like using connectivity_plus to query the system's network connectivity
// state.
AlwaysConnectedConnectivityManager(),
// This kind of negotiator tells the connection how to handle the stream
// negotiations. The [ClientToServerNegotiator] allows to connect to the server
// as a regular client. Another negotiator would be the [ComponentToServerNegotiator] that
// allows for connections to the server where we're acting as a component.
ClientToServerNegotiator(),
// A wrapper around any kind of connection. In this case, we use the [TCPSocketWrapper], which
// uses a dart:io Socket/SecureSocket to connect to the server. If you want, you can also
// provide your own socket to use, for example, WebSockets or any other connection
// mechanism.
TCPSocketWrapper(false),
)..connectionSettings = ConnectionSettings(
jid: xmppUser,
password: xmppPass,
host: xmppHost,
port: xmppPort,
);
// Register a set of "managers" that provide you with implementations of various
// XEPs. Some have interdependencies, which need to be met. However, this example keeps
// it simple and just registers a [MessageManager], which has no required dependencies.
await connection.registerManagers([
// The [MessageManager] handles receiving and sending <message /> stanzas.
MessageManager(),
]);
// Feature negotiators are objects that tell the connection negotiator what stream features
// we can negotiate and enable. moxxmpp negotiators always try to enable their features.
await connection.registerFeatureNegotiators([
// This negotiator authenticates to the server using SASL PLAIN with the provided
// credentials.
SaslPlainNegotiator(),
// This negotiator attempts to bind a resource. By default, it's always a random one.
ResourceBindingNegotiator(),
// This negotiator attempts to do StartTLS before authenticating.
StartTlsNegotiator(),
]);
// Set up a stream handler for the connection's event stream. Managers and negotiators
// may trigger certain events. The [MessageManager], for example, triggers a [MessageEvent]
// whenever a message is received. If other managers are registered that parse a message's
// contents, then they can add their data to the event.
connection.asBroadcastStream().listen((event) {
if (event is! MessageEvent) {
return;
}
// The text body (contents of the <body /> element) are returned as a
// [MessageBodyData] object. However, a message does not have to contain a
// body, so it is nullable.
final body = event.extensions.get<MessageBodyData>()?.body;
print('[<-- ${event.from}] $body');
});
// Connect to the server.
final result = await connection.connect(
// This flag indicates that we want to reconnect in case something happens.
shouldReconnect: true,
// This flag indicates that we want the returned Future to only resolve
// once the stream negotiations are done and no negotiator has any feature left
// to negotiate.
waitUntilLogin: true,
);
// Check if the connection was successful. [connection.connect] can return a boolean
// to indicate success or a [XmppError] in case the connection attempt failed.
if (!result.isType<bool>()) {
print('Failed to connect to server');
return;
}
}

View File

@@ -3,7 +3,7 @@ import 'package:moxxmpp_socket_tcp/moxxmpp_socket_tcp.dart';
/// A simple socket for examples that allows injection of SRV records (since
/// we cannot use moxdns here).
class ExampleTCPSocketWrapper extends TCPSocketWrapper {
ExampleTCPSocketWrapper(this.srvRecord);
ExampleTCPSocketWrapper(this.srvRecord, bool logData) : super(logData);
/// A potential SRV record to inject for testing.
final MoxSrvRecord? srvRecord;

View File

@@ -3,7 +3,7 @@ description: A collection of samples for moxxmpp.
version: 1.0.0
environment:
sdk: '>=2.18.0 <3.0.0'
sdk: '>=3.0.0 <4.0.0'
dependencies:
args: 2.4.1
@@ -12,10 +12,10 @@ dependencies:
logging: ^1.0.2
moxxmpp:
hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: 0.3.1
version: 0.4.0
moxxmpp_socket_tcp:
hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: 0.3.1
version: 0.4.0
omemo_dart:
hosted: https://git.polynom.me/api/packages/PapaTutuWawa/pub
version: ^0.5.1

44
flake.lock generated
View File

@@ -7,11 +7,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1689798050,
"narHash": "sha256-ZyFPra7N0MF803o55dYQQyX9b/BmXr6QTCyN7slRThY=",
"lastModified": 1727554699,
"narHash": "sha256-puBCNL5PW7Pej+6Srmi2YjEgNeE015NFe33hbkkLqeQ=",
"owner": "tadfisher",
"repo": "android-nixpkgs",
"rev": "9aa0e2990da86de8ca203af313668851dcb9ea6e",
"rev": "bc34ef1c71fe9eafcfb1d637b431fca83d746625",
"type": "github"
},
"original": {
@@ -25,15 +25,14 @@
"nixpkgs": [
"android-nixpkgs",
"nixpkgs"
],
"systems": "systems"
]
},
"locked": {
"lastModified": 1688380630,
"narHash": "sha256-8ilApWVb1mAi4439zS3iFeIT0ODlbrifm/fegWwgHjA=",
"lastModified": 1722113426,
"narHash": "sha256-Yo/3loq572A8Su6aY5GP56knpuKYRvM2a1meP9oJZCw=",
"owner": "numtide",
"repo": "devshell",
"rev": "f9238ec3d75cefbb2b42a44948c4e8fb1ae9a205",
"rev": "67cce7359e4cd3c45296fb4aaf6a19e2a9c757ae",
"type": "github"
},
"original": {
@@ -44,14 +43,14 @@
},
"flake-utils": {
"inputs": {
"systems": "systems_2"
"systems": "systems"
},
"locked": {
"lastModified": 1689068808,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"lastModified": 1726560853,
"narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
"type": "github"
},
"original": {
@@ -61,12 +60,15 @@
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1667395993,
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
"lastModified": 1692799911,
"narHash": "sha256-3eihraek4qL744EvQXsK1Ha6C3CR7nnT8X2qWap4RNk=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f",
"rev": "f9e7cf818399d17d347f847525c5a5a8032e4e44",
"type": "github"
},
"original": {
@@ -77,11 +79,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1689679375,
"narHash": "sha256-LHUC52WvyVDi9PwyL1QCpaxYWBqp4ir4iL6zgOkmcb8=",
"lastModified": 1727348695,
"narHash": "sha256-J+PeFKSDV+pHL7ukkfpVzCOO7mBSrrpJ3svwBFABbhI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "684c17c429c42515bafb3ad775d2a710947f3d67",
"rev": "1925c603f17fc89f4c8f6bf6f631a802ad85d784",
"type": "github"
},
"original": {
@@ -93,11 +95,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1689752456,
"narHash": "sha256-VOChdECcEI8ixz8QY+YC4JaNEFwQd1V8bA0G4B28Ki0=",
"lastModified": 1727586919,
"narHash": "sha256-e/YXG0tO5GWHDS8QQauj8aj4HhXEm602q9swrrlTlKQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7f256d7da238cb627ef189d56ed590739f42f13b",
"rev": "2dcd9c55e8914017226f5948ac22c53872a13ee2",
"type": "github"
},
"original": {

View File

@@ -15,20 +15,22 @@
};
};
# Everything to make Flutter happy
sdk = android-nixpkgs.sdk.${system} (sdkPkgs: with sdkPkgs; [
cmdline-tools-latest
build-tools-30-0-3
build-tools-33-0-2
build-tools-34-0-0
platform-tools
emulator
patcher-v4
platforms-android-28
platforms-android-29
platforms-android-30
platforms-android-31
platforms-android-33
]);
android = pkgs.androidenv.composeAndroidPackages {
# TODO: Find a way to pin these
#toolsVersion = "26.1.1";
#platformToolsVersion = "31.0.3";
#buildToolsVersions = [ "31.0.0" ];
#includeEmulator = true;
#emulatorVersion = "30.6.3";
platformVersions = [ "28" ];
includeSources = false;
includeSystemImages = true;
systemImageTypes = [ "default" ];
abiVersions = [ "x86_64" ];
includeNDK = false;
useGoogleAPIs = false;
useGoogleTVAddOns = false;
};
lib = pkgs.lib;
pinnedJDK = pkgs.jdk17;
@@ -68,7 +70,7 @@
};
in pkgs.mkShell {
buildInputs = with pkgs; [
flutter37 pinnedJDK sdk dart # Dart
flutter pinnedJDK android.platform-tools dart # Dart
gitlint # Code hygiene
ripgrep # General utilities
@@ -100,13 +102,13 @@
CPATH = "${pkgs.xorg.libX11.dev}/include:${pkgs.xorg.xorgproto}/include";
LD_LIBRARY_PATH = with pkgs; lib.makeLibraryPath [ atk cairo epoxy gdk-pixbuf glib gtk3 harfbuzz pango ];
ANDROID_SDK_ROOT = "${sdk}/share/android-sdk";
ANDROID_HOME = "${sdk}/share/android-sdk";
ANDROID_SDK_ROOT = "${android.androidsdk}/share/android-sdk";
ANDROID_HOME = "${android.androidsdk}/share/android-sdk";
JAVA_HOME = pinnedJDK;
# Fix an issue with Flutter using an older version of aapt2, which does not know
# an used parameter.
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${sdk}/share/android-sdk/build-tools/34.0.0/aapt2";
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${android.androidsdk}/share/android-sdk/build-tools/34.0.0/aapt2";
};
apps = {

View File

@@ -1,2 +1,4 @@
set -ex
prosodyctl --config ./prosody.cfg.lua register testuser1 localhost abc123
prosodyctl --config ./prosody.cfg.lua register testuser2 localhost abc123

View File

@@ -3,14 +3,16 @@ description: A sample command-line application.
version: 1.0.0
environment:
sdk: '>=2.18.0 <3.0.0'
sdk: ">=3.0.0 <4.0.0"
dependencies:
logging: ^1.0.2
moxxmpp: 0.3.0
moxxmpp_socket_tcp: 0.3.0
logging: ^1.3.0
moxxmpp:
path: ../packages/moxxmpp
moxxmpp_socket_tcp:
path: ../packages/moxxmpp_socket_tcp
dev_dependencies:
lints: ^2.0.0
test: ^1.16.0
very_good_analysis: ^3.0.1
build_runner: ^2.4.13
test: ^1.25.8
very_good_analysis: ^6.0.0

View File

@@ -4,6 +4,8 @@ import 'package:moxxmpp_socket_tcp/moxxmpp_socket_tcp.dart';
import 'package:test/test.dart';
class TestingTCPSocketWrapper extends TCPSocketWrapper {
TestingTCPSocketWrapper() : super(true);
@override
bool onBadCertificate(dynamic certificate, String domain) {
return true;

View File

@@ -4,6 +4,8 @@ import 'package:moxxmpp_socket_tcp/moxxmpp_socket_tcp.dart';
import 'package:test/test.dart';
class TestingTCPSocketWrapper extends TCPSocketWrapper {
TestingTCPSocketWrapper() : super(true);
@override
bool onBadCertificate(dynamic certificate, String domain) {
return true;
@@ -27,7 +29,7 @@ void main() {
ClientToServerNegotiator(),
TestingTCPSocketWrapper(),
)..connectionSettings = ConnectionSettings(
jid: JID.fromString('testuser@localhost'),
jid: JID.fromString('testuser1@localhost'),
password: 'abc123',
host: '127.0.0.1',
port: 5222,
@@ -40,6 +42,8 @@ void main() {
]);
await conn.registerFeatureNegotiators([
SaslPlainNegotiator(),
SaslScramNegotiator(9, '', '', ScramHashType.sha1),
SaslScramNegotiator(10, '', '', ScramHashType.sha256),
ResourceBindingNegotiator(),
FASTSaslNegotiator(),
Bind2Negotiator(),

View File

@@ -1,3 +1,6 @@
## 0.4.1
- Moved FAST from staging to xep_0484.dart
## 0.4.0
- **BREAKING**: Remove `lastResource` from `XmppConnection`'s `connect` method. Instead, set the `StreamManagementNegotiator`'s `resource` attribute instead. Since the resource can only really be restored by stream management, this is no issue.
@@ -22,6 +25,10 @@
- *BREAKING*: Rename `UserAvatarManager`'s `getUserAvatar` to `getUserAvatarData`. It now also requires the id of the avatar to fetch
- *BREAKING*: `UserAvatarManager`'s `getAvatarId` with `getLatestMetadata`.
- The `PubSubManager` now supports PubSub's `max_items` in `getItems`.
- *BREAKING*: `vCardManager`'s `VCardAvatarUpdatedEvent` no longer automatically requests the newest VCard avatar.
- *BREAKING*: `XmppConnection` now tries to ensure that incoming data is processed in-order. The only exception are awaited stanzas as they are allowed to bypass the queue.
- *BREAKING*: If a stanza handler causes an exception, the handler is simply skipped while processing.
- Add better logging around what stanza handler is running and if they end processing early.
## 0.3.1

View File

@@ -39,7 +39,6 @@ export 'package:moxxmpp/src/socket.dart';
export 'package:moxxmpp/src/stanza.dart';
export 'package:moxxmpp/src/stringxml.dart';
export 'package:moxxmpp/src/util/typed_map.dart';
export 'package:moxxmpp/src/xeps/staging/fast.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';
@@ -47,6 +46,7 @@ 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_0045/errors.dart';
export 'package:moxxmpp/src/xeps/xep_0045/events.dart';
export 'package:moxxmpp/src/xeps/xep_0045/types.dart';
export 'package:moxxmpp/src/xeps/xep_0045/xep_0045.dart';
export 'package:moxxmpp/src/xeps/xep_0054.dart';
@@ -95,3 +95,4 @@ export 'package:moxxmpp/src/xeps/xep_0447.dart';
export 'package:moxxmpp/src/xeps/xep_0448.dart';
export 'package:moxxmpp/src/xeps/xep_0449.dart';
export 'package:moxxmpp/src/xeps/xep_0461.dart';
export 'package:moxxmpp/src/xeps/xep_0484.dart';

View File

@@ -1,35 +1,13 @@
import 'dart:async';
import 'package:meta/meta.dart';
import 'package:moxxmpp/src/jid.dart';
import 'package:moxxmpp/src/stringxml.dart';
import 'package:synchronized/synchronized.dart';
/// A surrogate key for awaiting stanzas.
@immutable
class _StanzaSurrogateKey {
const _StanzaSurrogateKey(this.sentTo, this.id, this.tag);
/// (JID we sent a stanza to, the id of the sent stanza, the tag of the sent stanza).
// ignore: avoid_private_typedef_functions
typedef _StanzaCompositeKey = (String?, String, String);
/// The JID the original stanza was sent to. We expect the result to come from the
/// same JID.
final String sentTo;
/// The ID of the original stanza. We expect the result to have the same ID.
final String id;
/// The tag name of the stanza.
final String tag;
@override
int get hashCode => sentTo.hashCode ^ id.hashCode ^ tag.hashCode;
@override
bool operator ==(Object other) {
return other is _StanzaSurrogateKey &&
other.sentTo == sentTo &&
other.id == id &&
other.tag == tag;
}
}
/// Callback function that returns the bare JID of the connection as a String.
typedef GetBareJidCallback = String Function();
/// This class handles the await semantics for stanzas. Stanzas are given a "unique"
/// key equal to the tuple (to, id, tag) with which their response is identified.
@@ -40,8 +18,12 @@ class _StanzaSurrogateKey {
///
/// This class also handles some "edge cases" of RFC 6120, like an empty "from" attribute.
class StanzaAwaiter {
StanzaAwaiter(this._bareJidCallback);
final GetBareJidCallback _bareJidCallback;
/// The pending stanzas, identified by their surrogate key.
final Map<_StanzaSurrogateKey, Completer<XMLNode>> _pending = {};
final Map<_StanzaCompositeKey, Completer<XMLNode>> _pending = {};
/// The critical section for accessing [StanzaAwaiter._pending].
final Lock _lock = Lock();
@@ -52,30 +34,33 @@ class StanzaAwaiter {
/// [tag] is the stanza's tag name.
///
/// Returns a future that might resolve to the response to the stanza.
Future<Future<XMLNode>> addPending(String to, String id, String tag) async {
Future<Future<XMLNode>> addPending(String? to, String id, String tag) async {
// Check if we want to send a stanza to our bare JID and replace it with null.
final processedTo = to != null && to == _bareJidCallback() ? null : to;
final completer = await _lock.synchronized(() {
final completer = Completer<XMLNode>();
_pending[_StanzaSurrogateKey(to, id, tag)] = completer;
_pending[(processedTo, id, tag)] = completer;
return completer;
});
return completer.future;
}
/// Checks if the stanza [stanza] is being awaited. [bareJid] is the bare JID of
/// the connection.
/// Checks if the stanza [stanza] is being awaited.
/// If [stanza] is awaited, resolves the future and returns true. If not, returns
/// false.
Future<bool> onData(XMLNode stanza, JID bareJid) async {
assert(bareJid.isBare(), 'bareJid must be bare');
Future<bool> onData(XMLNode stanza) async {
final id = stanza.attributes['id'] as String?;
if (id == null) return false;
final key = _StanzaSurrogateKey(
// Section 8.1.2.1 § 3 of RFC 6120 says that an empty "from" indicates that the
// attribute is implicitly from our own bare JID.
stanza.attributes['from'] as String? ?? bareJid.toString(),
// Check if we want to send a stanza to our bare JID and replace it with null.
final from = stanza.attributes['from'] as String?;
final processedFrom =
from != null && from == _bareJidCallback() ? null : from;
final key = (
processedFrom,
id,
stanza.tag,
);
@@ -91,4 +76,19 @@ class StanzaAwaiter {
return false;
});
}
/// Checks if [stanza] represents a stanza that is awaited. Returns true, if [stanza]
/// is awaited. False, if not.
Future<bool> isAwaited(XMLNode stanza) async {
final id = stanza.attributes['id'] as String?;
if (id == null) return false;
final key = (
stanza.attributes['from'] as String?,
id,
stanza.tag,
);
return _lock.synchronized(() => _pending.containsKey(key));
}
}

View File

@@ -25,12 +25,12 @@ import 'package:moxxmpp/src/settings.dart';
import 'package:moxxmpp/src/socket.dart';
import 'package:moxxmpp/src/stanza.dart';
import 'package:moxxmpp/src/stringxml.dart';
import 'package:moxxmpp/src/util/incoming_queue.dart';
import 'package:moxxmpp/src/util/queue.dart';
import 'package:moxxmpp/src/util/typed_map.dart';
import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart';
import 'package:moxxmpp/src/xeps/xep_0198/xep_0198.dart';
import 'package:moxxmpp/src/xeps/xep_0352.dart';
import 'package:synchronized/synchronized.dart';
import 'package:uuid/uuid.dart';
/// The states the XmppConnection can be in
@@ -49,6 +49,19 @@ enum XmppConnectionState {
error
}
/// (The actual stanza handler, Name of the owning manager).
typedef _StanzaHandlerWrapper = (StanzaHandler, String);
/// Wrapper around [stanzaHandlerSortComparator] for [_StanzaHandlerWrapper].
int _stanzaHandlerWrapperSortComparator(
_StanzaHandlerWrapper a,
_StanzaHandlerWrapper b,
) {
final (ha, _) = a;
final (hb, _) = b;
return stanzaHandlerSortComparator(ha, hb);
}
/// This class is a connection to the server.
class XmppConnection {
XmppConnection(
@@ -58,7 +71,11 @@ class XmppConnection {
this._socket, {
this.connectingTimeout = const Duration(minutes: 2),
}) : _reconnectionPolicy = reconnectionPolicy,
_connectivityManager = connectivityManager {
_connectivityManager = connectivityManager,
assert(
_socket.getDataStream().isBroadcast,
"The socket's data stream must be a broadcast stream",
) {
// Allow the reconnection policy to perform reconnections by itself
_reconnectionPolicy.register(
_attemptReconnection,
@@ -77,9 +94,15 @@ class XmppConnection {
},
);
_stanzaAwaiter = StanzaAwaiter(
() => connectionSettings.jid.toBare().toString(),
);
_incomingStanzaQueue = IncomingStanzaQueue(handleXmlStream, _stanzaAwaiter);
_socketStream = _socket.getDataStream();
// TODO(Unknown): Handle on done
_socketStream.transform(_streamParser).forEach(handleXmlStream);
_socketStream
.transform(_streamParser)
.forEach(_incomingStanzaQueue.addStanza);
_socketStream.listen(_handleOnDataCallbacks);
_socket.getEventStream().listen(handleSocketEvent);
_stanzaQueue = AsyncStanzaQueue(
@@ -109,16 +132,16 @@ class XmppConnection {
final ConnectivityManager _connectivityManager;
/// A helper for handling await semantics with stanzas
final StanzaAwaiter _stanzaAwaiter = StanzaAwaiter();
late final StanzaAwaiter _stanzaAwaiter;
/// Sorted list of handlers that we call or incoming and outgoing stanzas
final List<StanzaHandler> _incomingStanzaHandlers =
final List<_StanzaHandlerWrapper> _incomingStanzaHandlers =
List.empty(growable: true);
final List<StanzaHandler> _incomingPreStanzaHandlers =
final List<_StanzaHandlerWrapper> _incomingPreStanzaHandlers =
List.empty(growable: true);
final List<StanzaHandler> _outgoingPreStanzaHandlers =
final List<_StanzaHandlerWrapper> _outgoingPreStanzaHandlers =
List.empty(growable: true);
final List<StanzaHandler> _outgoingPostStanzaHandlers =
final List<_StanzaHandlerWrapper> _outgoingPostStanzaHandlers =
List.empty(growable: true);
final StreamController<XmppEvent> _eventStreamController =
StreamController.broadcast();
@@ -157,10 +180,6 @@ class XmppConnection {
T? getNegotiatorById<T extends XmppFeatureNegotiatorBase>(String id) =>
_negotiationsHandler.getNegotiatorById<T>(id);
/// Prevent data from being passed to _currentNegotiator.negotiator while the negotiator
/// is still running.
final Lock _negotiationLock = Lock();
/// The logger for the class
final Logger _log = Logger('XmppConnection');
@@ -169,6 +188,8 @@ class XmppConnection {
bool get isAuthenticated => _isAuthenticated;
late final IncomingStanzaQueue _incomingStanzaQueue;
late final AsyncStanzaQueue _stanzaQueue;
/// Returns the JID we authenticate with and add the resource that we have bound.
@@ -198,18 +219,25 @@ class XmppConnection {
_xmppManagers[manager.id] = manager;
_incomingStanzaHandlers.addAll(manager.getIncomingStanzaHandlers());
_incomingPreStanzaHandlers.addAll(manager.getIncomingPreStanzaHandlers());
_outgoingPreStanzaHandlers.addAll(manager.getOutgoingPreStanzaHandlers());
_outgoingPostStanzaHandlers
.addAll(manager.getOutgoingPostStanzaHandlers());
_incomingStanzaHandlers.addAll(
manager.getIncomingStanzaHandlers().map((h) => (h, manager.name)),
);
_incomingPreStanzaHandlers.addAll(
manager.getIncomingPreStanzaHandlers().map((h) => (h, manager.name)),
);
_outgoingPreStanzaHandlers.addAll(
manager.getOutgoingPreStanzaHandlers().map((h) => (h, manager.name)),
);
_outgoingPostStanzaHandlers.addAll(
manager.getOutgoingPostStanzaHandlers().map((h) => (h, manager.name)),
);
}
// Sort them
_incomingStanzaHandlers.sort(stanzaHandlerSortComparator);
_incomingPreStanzaHandlers.sort(stanzaHandlerSortComparator);
_outgoingPreStanzaHandlers.sort(stanzaHandlerSortComparator);
_outgoingPostStanzaHandlers.sort(stanzaHandlerSortComparator);
_incomingStanzaHandlers.sort(_stanzaHandlerWrapperSortComparator);
_incomingPreStanzaHandlers.sort(_stanzaHandlerWrapperSortComparator);
_outgoingPreStanzaHandlers.sort(_stanzaHandlerWrapperSortComparator);
_outgoingPostStanzaHandlers.sort(_stanzaHandlerWrapperSortComparator);
// Run the post register callbacks
for (final manager in _xmppManagers.values) {
@@ -290,6 +318,13 @@ class XmppConnection {
return getManagerById(csiManager);
}
/// Called whenever we receive data on the socket.
Future<void> _handleOnDataCallbacks(String _) async {
for (final manager in _xmppManagers.values) {
unawaited(manager.onData());
}
}
/// Attempts to reconnect to the server by following an exponential backoff.
Future<void> _attemptReconnection() async {
_log.finest('_attemptReconnection: Setting state to notConnected');
@@ -475,7 +510,7 @@ class XmppConnection {
false,
false,
newStanza,
TypedMap(),
details.extensions ?? TypedMap(),
encrypted: details.encrypted,
shouldEncrypt: details.shouldEncrypt,
forceEncryption: details.forceEncryption,
@@ -515,7 +550,7 @@ class XmppConnection {
// A stanza with no to attribute is for direct processing by the server. As such,
// we can correlate it by just *assuming* we have that attribute
// (RFC 6120 Section 8.1.1.1)
data.stanza.to ?? connectionSettings.jid.toBare().toString(),
data.stanza.to,
data.stanza.id!,
data.stanza.tag,
)
@@ -650,15 +685,30 @@ class XmppConnection {
/// call its callback and end the processing if the callback returned true; continue
/// if it returned false.
Future<StanzaHandlerData> _runStanzaHandlers(
List<StanzaHandler> handlers,
List<_StanzaHandlerWrapper> handlers,
Stanza stanza, {
StanzaHandlerData? initial,
}) async {
var state = initial ?? StanzaHandlerData(false, false, stanza, TypedMap());
for (final handler in handlers) {
for (final handlerRaw in handlers) {
final (handler, managerName) = handlerRaw;
if (handler.matches(state.stanza)) {
state = await handler.callback(state.stanza, state);
if (state.done || state.cancel) return state;
_log.finest(
'Running handler for ${stanza.tag} (${stanza.attributes["id"]}) of $managerName',
);
try {
state = await handler.callback(state.stanza, state);
} catch (ex) {
_log.severe(
'Handler from $managerName for ${stanza.tag} (${stanza.attributes["id"]}) failed with "$ex"',
);
}
if (state.done || state.cancel) {
_log.finest(
'Processing ended early for ${stanza.tag} (${stanza.attributes["id"]}) by $managerName',
);
return state;
}
}
}
@@ -743,7 +793,6 @@ class XmppConnection {
final awaited = await _stanzaAwaiter.onData(
incomingPreHandlers.stanza,
connectionSettings.jid.toBare(),
);
if (awaited) {
return;
@@ -802,14 +851,12 @@ class XmppConnection {
// causing (a) the negotiator to become confused and (b) the stanzas/nonzas to be
// missed. This causes the data to wait while the negotiator is running and thus
// prevent this issue.
await _negotiationLock.synchronized(() async {
if (_routingState != RoutingState.negotiating) {
unawaited(handleXmlStream(event));
return;
}
if (_routingState != RoutingState.negotiating) {
unawaited(handleXmlStream(event));
return;
}
await _negotiationsHandler.negotiate(event);
});
await _negotiationsHandler.negotiate(event);
break;
case RoutingState.handleStanzas:
await _handleStanza(node);

View File

@@ -184,16 +184,12 @@ class UserAvatarUpdatedEvent extends XmppEvent {
class VCardAvatarUpdatedEvent extends XmppEvent {
VCardAvatarUpdatedEvent(
this.jid,
this.base64,
this.hash,
);
/// The JID of the entity that updated their avatar.
final JID jid;
/// The base64-encoded avatar data.
final String base64;
/// The SHA-1 hash of the avatar.
final String hash;
}

View File

@@ -80,6 +80,9 @@ abstract class XmppManagerBase {
/// handler's priority, the earlier it is run.
List<NonzaHandler> getNonzaHandlers() => [];
/// Whenever the socket receives data, this method is called, if it is non-null.
Future<void> onData() async {}
/// Return a list of features that should be included in a disco response.
List<String> getDiscoFeatures() => [];

View File

@@ -66,7 +66,7 @@ class MessageManager extends XmppManagerBase {
stanzaTag: 'message',
callback: _onMessage,
priority: messageHandlerPriority,
)
),
];
@override
@@ -117,6 +117,7 @@ class MessageManager extends XmppManagerBase {
.flattened
.toList(),
),
extensions: extensions,
awaitable: false,
),
);

View File

@@ -13,6 +13,7 @@ const subscriptionPreApprovalXmlns = 'urn:xmpp:features:pre-approval';
// XEP-0004
const dataFormsXmlns = 'jabber:x:data';
const formVarFormType = 'FORM_TYPE';
// XEP-0030
const discoInfoXmlns = 'http://jabber.org/protocol/disco#info';
@@ -23,6 +24,8 @@ const extendedAddressingXmlns = 'http://jabber.org/protocol/address';
// XEP-0045
const mucXmlns = 'http://jabber.org/protocol/muc';
const mucUserXmlns = 'http://jabber.org/protocol/muc#user';
const roomInfoFormType = 'http://jabber.org/protocol/muc#roominfo';
// XEP-0054
const vCardTempXmlns = 'vcard-temp';
@@ -163,7 +166,6 @@ const stickersXmlns = 'urn:xmpp:stickers: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

@@ -57,9 +57,10 @@ class _ChunkedConversionBuffer<S, T> {
}
/// A buffer to put between a socket's input and a full XML stream.
class XMPPStreamParser extends StreamTransformerBase<String, XMPPStreamObject> {
final StreamController<XMPPStreamObject> _streamController =
StreamController<XMPPStreamObject>();
class XMPPStreamParser
extends StreamTransformerBase<String, List<XMPPStreamObject>> {
final StreamController<List<XMPPStreamObject>> _streamController =
StreamController<List<XMPPStreamObject>>();
/// Turns a String into a list of [XmlEvent]s in a chunked fashion.
_ChunkedConversionBuffer<String, XmlEvent> _eventBuffer =
@@ -117,13 +118,14 @@ class XMPPStreamParser extends StreamTransformerBase<String, XMPPStreamObject> {
}
@override
Stream<XMPPStreamObject> bind(Stream<String> stream) {
Stream<List<XMPPStreamObject>> bind(Stream<String> stream) {
// We do not want to use xml's toXmlEvents and toSubtreeEvents methods as they
// create streams we cannot close. We need to be able to destroy and recreate an
// XML parser whenever we start a new connection.
stream.listen((input) {
final events = _eventBuffer.convert(input);
final streamHeaderEvents = _streamHeaderSelector.convert(events);
final objects = List<XMPPStreamObject>.empty(growable: true);
// Process the stream header separately.
for (final event in streamHeaderEvents) {
@@ -135,7 +137,7 @@ class XMPPStreamParser extends StreamTransformerBase<String, XMPPStreamObject> {
continue;
}
_streamController.add(
objects.add(
XMPPStreamHeader(
Map<String, String>.fromEntries(
event.attributes.map((attr) {
@@ -151,13 +153,15 @@ class XMPPStreamParser extends StreamTransformerBase<String, XMPPStreamObject> {
final children = _childBuffer.convert(childEvents);
for (final node in children) {
if (node.nodeType == XmlNodeType.ELEMENT) {
_streamController.add(
objects.add(
XMPPStreamElement(
XMLNode.fromXmlElement(node as XmlElement),
),
);
}
}
_streamController.add(objects);
});
return _streamController.stream;

View File

@@ -66,7 +66,7 @@ class PresenceManager extends XmppManagerBase {
stanzaTag: 'presence',
callback: _onPresence,
priority: presenceHandlerPriority,
)
),
];
@override

View File

@@ -96,7 +96,9 @@ class SaslPlainNegotiator extends Sasl2AuthenticationNegotiator {
@override
Future<Result<bool, NegotiatorError>> onSasl2Success(XMLNode response) async {
state = NegotiatorState.done;
if (pickedForSasl2) {
state = NegotiatorState.done;
}
return const Result(true);
}

View File

@@ -246,6 +246,9 @@ class SaslScramNegotiator extends Sasl2AuthenticationNegotiator {
bool _checkSignature(String base64Signature) {
final signature =
parseKeyValue(utf8.decode(base64.decode(base64Signature)));
_log.finest(
'Expecting signature: "$_serverSignature", got: "${signature["v"]}"',
);
return signature['v']! == _serverSignature;
}
@@ -360,6 +363,11 @@ class SaslScramNegotiator extends Sasl2AuthenticationNegotiator {
@override
Future<Result<bool, NegotiatorError>> onSasl2Success(XMLNode response) async {
// Don't do anything if we have not been picked for SASL2.
if (!pickedForSasl2) {
return const Result(true);
}
// When we're done with SASL2, check the additional data to verify the server
// signature.
state = NegotiatorState.done;

View File

@@ -122,7 +122,7 @@ class RosterManager extends XmppManagerBase {
tagName: 'query',
tagXmlns: rosterXmlns,
callback: _onRosterPush,
)
),
];
@override
@@ -182,8 +182,18 @@ class RosterManager extends XmppManagerBase {
/// Shared code between requesting rosters without and with roster versioning, if
/// the server deems a regular roster response more efficient than n roster pushes.
///
/// [query] is the <query /> child of the iq, if available.
///
/// If roster versioning was used, then [requestedRosterVersion] is the version
/// we requested the roster with.
///
/// Note that if roster versioning is used and the server returns us an empty iq,
/// it means that the roster did not change since the last version. In that case,
/// we do nothing and just return. The roster state manager will not be notified.
Future<Result<RosterRequestResult, RosterError>> _handleRosterResponse(
XMLNode? query,
String? requestedRosterVersion,
) async {
final List<XmppRosterItem> items;
String? rosterVersion;
@@ -204,6 +214,14 @@ class RosterManager extends XmppManagerBase {
.toList();
rosterVersion = query.attributes['ver'] as String?;
} else if (requestedRosterVersion != null) {
// Skip the handleRosterFetch call since nothing changed.
return Result(
RosterRequestResult(
[],
requestedRosterVersion,
),
);
} 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',
@@ -258,7 +276,7 @@ class RosterManager extends XmppManagerBase {
}
final responseQuery = response.firstTag('query', xmlns: rosterXmlns);
return _handleRosterResponse(responseQuery);
return _handleRosterResponse(responseQuery, rosterVersion);
}
/// Requests a series of roster pushes according to RFC6121. Requires that the server
@@ -266,6 +284,7 @@ class RosterManager extends XmppManagerBase {
Future<Result<RosterRequestResult?, RosterError>>
requestRosterPushes() async {
final attrs = getAttributes();
final rosterVersion = await _stateManager.getRosterVersion();
final result = (await attrs.sendStanza(
StanzaDetails(
Stanza.iq(
@@ -275,9 +294,9 @@ class RosterManager extends XmppManagerBase {
tag: 'query',
xmlns: rosterXmlns,
attributes: {
'ver': await _stateManager.getRosterVersion() ?? '',
'ver': rosterVersion ?? '',
},
)
),
],
),
),
@@ -289,7 +308,7 @@ class RosterManager extends XmppManagerBase {
}
final query = result.firstTag('query', xmlns: rosterXmlns);
return _handleRosterResponse(query);
return _handleRosterResponse(query, rosterVersion);
}
bool rosterVersioningAvailable() {
@@ -319,14 +338,12 @@ class RosterManager extends XmppManagerBase {
tag: 'item',
attributes: <String, String>{
'jid': jid,
...title == jid.split('@')[0]
? <String, String>{}
: <String, String>{'name': title}
if (title == jid.split('@')[0]) 'name': title,
},
children: (groups ?? [])
.map((group) => XMLNode(tag: 'group', text: group))
.toList(),
)
),
],
),
],
@@ -357,13 +374,13 @@ class RosterManager extends XmppManagerBase {
children: [
XMLNode(
tag: 'item',
attributes: <String, String>{
attributes: {
'jid': jid,
'subscription': 'remove'
'subscription': 'remove',
},
)
),
],
)
),
],
),
),

View File

@@ -7,6 +7,7 @@ import 'package:moxxmpp/src/util/typed_map.dart';
class StanzaDetails {
const StanzaDetails(
this.stanza, {
this.extensions,
this.addId = true,
this.awaitable = true,
this.shouldEncrypt = true,
@@ -19,6 +20,9 @@ class StanzaDetails {
/// The stanza to send.
final Stanza stanza;
/// The extension data used for constructing the stanza.
final TypedMap<StanzaHandlerExtension>? extensions;
/// Flag indicating whether a stanza id should be added before sending.
final bool addId;
@@ -98,6 +102,8 @@ class RemoteServerTimeoutError extends StanzaError {
/// An unknown error.
class UnknownStanzaError extends StanzaError {}
const _stanzaNotDefined = Object();
class Stanza extends XMLNode {
// ignore: use_super_parameters
Stanza({
@@ -212,7 +218,7 @@ class Stanza extends XMLNode {
Stanza copyWith({
String? id,
String? from,
Object? from = _stanzaNotDefined,
String? to,
String? type,
List<XMLNode>? children,
@@ -221,7 +227,7 @@ class Stanza extends XMLNode {
return Stanza(
tag: tag,
to: to ?? this.to,
from: from ?? this.from,
from: from != _stanzaNotDefined ? from as String? : this.from,
id: id ?? this.id,
type: type ?? this.type,
children: children ?? this.children,
@@ -244,15 +250,14 @@ XMLNode buildErrorElement(String type, String condition, {String? text}) {
XMLNode.xmlns(
tag: condition,
xmlns: fullStanzaXmlns,
children: text != null
? [
XMLNode.xmlns(
tag: 'text',
xmlns: fullStanzaXmlns,
text: text,
)
]
: [],
children: [
if (text != null)
XMLNode.xmlns(
tag: 'text',
xmlns: fullStanzaXmlns,
text: text,
),
],
),
],
);

View File

@@ -0,0 +1,97 @@
import 'dart:async';
import 'dart:collection';
import 'package:logging/logging.dart';
import 'package:moxxmpp/src/awaiter.dart';
import 'package:moxxmpp/src/parser.dart';
import 'package:synchronized/synchronized.dart';
/// A queue for incoming [XMPPStreamObject]s to ensure "in order"
/// processing (except for stanzas that are awaited).
class IncomingStanzaQueue {
IncomingStanzaQueue(this._callback, this._stanzaAwaiter);
/// The queue for storing the completer of each
/// incoming stanza (or stream object to be precise).
/// Only access while holding the lock [_lock].
final Queue<Completer<void>> _queue = Queue();
/// Flag indicating whether a callback is already running (true)
/// or not. "a callback" and not "the callback" because awaited stanzas
/// are allowed to bypass the queue.
/// Only access while holding the lock [_lock].
bool _isRunning = false;
/// The function to call to process an incoming stream object.
final Future<void> Function(XMPPStreamObject) _callback;
/// Lock guarding both [_queue] and [_isRunning].
final Lock _lock = Lock();
/// Logger.
final Logger _log = Logger('IncomingStanzaQueue');
final StanzaAwaiter _stanzaAwaiter;
Future<void> _processStreamObject(
Future<void>? future,
XMPPStreamObject object,
) async {
if (future == null) {
if (object is XMPPStreamElement) {
_log.finest(
'Bypassing queue for ${object.node.tag} (${object.node.attributes["id"]})',
);
}
return _callback(object);
}
// Wait for our turn.
await future;
// Run the callback.
await _callback(object);
// Run the next entry.
await _lock.synchronized(() {
if (_queue.isNotEmpty) {
_queue.removeFirst().complete();
} else {
_isRunning = false;
}
});
}
Future<void> addStanza(List<XMPPStreamObject> objects) async {
await _lock.synchronized(() async {
for (final object in objects) {
if (await canBypassQueue(object)) {
unawaited(
_processStreamObject(null, object),
);
continue;
}
final completer = Completer<void>();
if (_isRunning) {
_queue.add(completer);
} else {
_isRunning = true;
completer.complete();
}
unawaited(
_processStreamObject(completer.future, object),
);
}
});
}
Future<bool> canBypassQueue(XMPPStreamObject object) async {
if (object is XMPPStreamHeader) {
return false;
}
object as XMPPStreamElement;
return _stanzaAwaiter.isAwaited(object.node);
}
}

View File

@@ -10,14 +10,14 @@ class DataFormOption {
XMLNode toXml() {
return XMLNode(
tag: 'option',
attributes: label != null
? <String, dynamic>{'label': label}
: <String, dynamic>{},
attributes: {
if (label != null) 'label': label,
},
children: [
XMLNode(
tag: 'value',
text: value,
)
),
],
);
}
@@ -45,19 +45,22 @@ class DataFormField {
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>{}
if (varAttr != null) 'var': varAttr,
if (type != null) 'type': type,
if (label != null) 'label': label,
},
children: [
...description != null ? [XMLNode(tag: 'desc', text: description)] : [],
...isRequired ? [XMLNode(tag: 'required')] : [],
if (description != null)
XMLNode(
tag: 'desc',
text: description,
),
if (isRequired)
XMLNode(
tag: 'required',
),
...values.map((value) => XMLNode(tag: 'value', text: value)),
...options.map((option) => option.toXml())
...options.map((option) => option.toXml()),
],
);
}

View File

@@ -13,8 +13,10 @@ Stanza buildDiscoInfoQueryStanza(JID entity, String? node) {
XMLNode.xmlns(
tag: 'query',
xmlns: discoInfoXmlns,
attributes: node != null ? {'node': node} : {},
)
attributes: {
if (node != null) 'node': node,
},
),
],
);
}
@@ -27,8 +29,10 @@ Stanza buildDiscoItemsQueryStanza(JID entity, {String? node}) {
XMLNode.xmlns(
tag: 'query',
xmlns: discoItemsXmlns,
attributes: node != null ? {'node': node} : {},
)
attributes: {
if (node != null) 'node': node,
},
),
],
);
}

View File

@@ -19,13 +19,11 @@ class Identity {
XMLNode toXMLNode() {
return XMLNode(
tag: 'identity',
attributes: <String, dynamic>{
attributes: {
'category': category,
'type': type,
'name': name,
...lang == null
? <String, dynamic>{}
: <String, dynamic>{'xml:lang': lang}
if (lang != null) 'xml:lang': lang,
},
);
}

View File

@@ -17,3 +17,9 @@ class NoNicknameSpecified extends MUCError {}
/// them to be a member of a room, but they are not currently joined to
/// that room.
class RoomNotJoinedError extends MUCError {}
/// Indicates that the MUC forbids us from joining, i.e. when we're banned.
class JoinForbiddenError extends MUCError {}
/// Indicates that an unspecific error occurred while joining.
class MUCUnspecificError extends MUCError {}

View File

@@ -0,0 +1,72 @@
import 'package:moxxmpp/src/events.dart';
import 'package:moxxmpp/src/jid.dart';
import 'package:moxxmpp/src/xeps/xep_0045/types.dart';
/// Triggered when the MUC changes our nickname.
class OwnDataChangedEvent extends XmppEvent {
OwnDataChangedEvent(
this.roomJid,
this.nick,
this.affiliation,
this.role,
);
/// The JID of the room.
final JID roomJid;
/// Our nickname.
final String nick;
/// Our affiliation.
final Affiliation affiliation;
/// Our role.
final Role role;
}
/// Triggered when an entity joins the MUC.
class MemberJoinedEvent extends XmppEvent {
MemberJoinedEvent(this.roomJid, this.member);
/// The JID of the room.
final JID roomJid;
/// The new member.
final RoomMember member;
}
/// Triggered when an entity changes their presence in the MUC.
class MemberChangedEvent extends XmppEvent {
MemberChangedEvent(this.roomJid, this.member);
/// The JID of the room.
final JID roomJid;
/// The new member.
final RoomMember member;
}
/// Triggered when an entity leaves the MUC.
class MemberLeftEvent extends XmppEvent {
MemberLeftEvent(this.roomJid, this.nick);
/// The JID of the room.
final JID roomJid;
/// The nick of the user who left.
final String nick;
}
/// Triggered when an entity changes their nick.
class MemberChangedNickEvent extends XmppEvent {
MemberChangedNickEvent(this.roomJid, this.oldNick, this.newNick);
/// The JID of the room.
final JID roomJid;
/// The original nick.
final String oldNick;
/// The new nick.
final String newNick;
}

View File

@@ -0,0 +1,2 @@
const selfPresenceStatus = '110';
const nicknameChangedStatus = '303';

View File

@@ -1,12 +1,77 @@
import 'package:collection/collection.dart';
import 'package:moxxmpp/src/jid.dart';
import 'package:moxxmpp/src/namespaces.dart';
import 'package:moxxmpp/src/xeps/xep_0004.dart';
import 'package:moxxmpp/src/xeps/xep_0030/types.dart';
class InvalidAffiliationException implements Exception {}
class InvalidRoleException implements Exception {}
enum Affiliation {
owner('owner'),
admin('admin'),
member('member'),
outcast('outcast'),
none('none');
const Affiliation(this.value);
factory Affiliation.fromString(String value) {
switch (value) {
case 'owner':
return Affiliation.owner;
case 'admin':
return Affiliation.admin;
case 'member':
return Affiliation.member;
case 'outcast':
return Affiliation.outcast;
case 'none':
return Affiliation.none;
default:
throw InvalidAffiliationException();
}
}
/// The value to use for an attribute referring to this affiliation.
final String value;
}
enum Role {
moderator('moderator'),
participant('participant'),
visitor('visitor'),
none('none');
const Role(this.value);
factory Role.fromString(String value) {
switch (value) {
case 'moderator':
return Role.moderator;
case 'participant':
return Role.participant;
case 'visitor':
return Role.visitor;
case 'none':
return Role.none;
default:
throw InvalidRoleException();
}
}
/// The value to use for an attribute referring to this role.
final String value;
}
class RoomInformation {
/// Represents information about a Multi-User Chat (MUC) room.
RoomInformation({
required this.jid,
required this.features,
required this.name,
this.roomInfo,
});
/// Constructs a [RoomInformation] object from a [DiscoInfo] object.
@@ -21,6 +86,11 @@ class RoomInformation {
name: discoInfo.identities
.firstWhere((i) => i.category == 'conference')
.name!,
roomInfo: discoInfo.extendedInfo.firstWhereOrNull((form) {
final field = form.getFieldByVar(formVarFormType);
return field?.type == 'hidden' &&
field?.values.first == roomInfoFormType;
}),
);
/// The JID of the Multi-User Chat (MUC) room.
@@ -31,13 +101,63 @@ class RoomInformation {
/// The name or title of the Multi-User Chat (MUC) room.
final String name;
/// The data form containing room information.
final DataForm? roomInfo;
}
/// The used message-id and an optional origin-id.
typedef PendingMessage = (String, String?);
/// An entity inside a MUC room. The name "member" here does not refer to an affiliation of member.
class RoomMember {
const RoomMember(this.nick, this.affiliation, this.role);
/// The entity's nickname.
final String nick;
/// The assigned affiliation.
final Affiliation affiliation;
/// The assigned role.
final Role role;
RoomMember copyWith({
String? nick,
Affiliation? affiliation,
Role? role,
}) {
return RoomMember(
nick ?? this.nick,
affiliation ?? this.affiliation,
role ?? this.role,
);
}
}
class RoomState {
RoomState({
required this.roomJid,
this.nick,
});
RoomState({required this.roomJid, this.nick, required this.joined}) {
pendingMessages = List<PendingMessage>.empty(growable: true);
}
/// The JID of the room.
final JID roomJid;
/// The nick we're joined with.
String? nick;
/// Flag whether we're joined and can process messages
bool joined;
/// Our own affiliation inside the MUC.
Affiliation? affiliation;
/// Our own role inside the MUC.
Role? role;
/// The list of messages that we sent and are waiting for their echo.
late final List<PendingMessage> pendingMessages;
/// "List" of entities inside the MUC.
final Map<String, RoomMember> members = {};
}

View File

@@ -1,28 +1,129 @@
import 'dart:async';
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/presence.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_0045/errors.dart';
import 'package:moxxmpp/src/xeps/xep_0045/events.dart';
import 'package:moxxmpp/src/xeps/xep_0045/status_codes.dart';
import 'package:moxxmpp/src/xeps/xep_0045/types.dart';
import 'package:moxxmpp/src/xeps/xep_0359.dart';
import 'package:synchronized/synchronized.dart';
/// (Room JID, nickname)
typedef MUCRoomJoin = (JID, String);
class MUCManager extends XmppManagerBase {
MUCManager() : super(mucManager);
@override
Future<bool> isSupported() async => true;
/// Map full JID to RoomState
/// Map a room's JID to its RoomState
final Map<JID, RoomState> _mucRoomCache = {};
/// Mapp a room's JID to a completer waiting for the completion of the join process.
final Map<JID, Completer<Result<bool, MUCError>>> _mucRoomJoinCompleter = {};
/// Cache lock
final Lock _cacheLock = Lock();
/// Flag indicating whether we joined the rooms added to the room list with
/// [prepareRoomList].
bool _joinedPreparedRooms = true;
@override
List<StanzaHandler> getIncomingStanzaHandlers() => [
StanzaHandler(
stanzaTag: 'message',
callback: _onMessage,
// Before the message handler
priority: -99,
),
StanzaHandler(
stanzaTag: 'presence',
callback: _onPresence,
tagName: 'x',
tagXmlns: mucUserXmlns,
// Before the PresenceManager
priority: PresenceManager.presenceHandlerPriority + 1,
),
];
@override
List<StanzaHandler> getOutgoingPreStanzaHandlers() => [
StanzaHandler(
stanzaTag: 'message',
callback: _onMessageSent,
),
];
@override
Future<void> onXmppEvent(XmppEvent event) async {
if (event is! StreamNegotiationsDoneEvent) {
return;
}
// Only attempt rejoining if we did not resume the stream and all
// prepared rooms are already joined.
if (event.resumed && _joinedPreparedRooms) {
return;
}
final mucJoins = List<MUCRoomJoin>.empty(growable: true);
await _cacheLock.synchronized(() async {
// Mark all groupchats as not joined.
for (final jid in _mucRoomCache.keys) {
_mucRoomCache[jid]!.joined = false;
_mucRoomJoinCompleter[jid] = Completer();
// Re-join all MUCs.
final state = _mucRoomCache[jid]!;
mucJoins.add((jid, state.nick!));
}
});
for (final join in mucJoins) {
final (jid, nick) = join;
await _sendMucJoin(
jid,
nick,
0,
);
}
_joinedPreparedRooms = true;
}
/// Prepares the internal room list to ensure that the rooms
/// [rooms] are joined once we are connected.
Future<void> prepareRoomList(List<MUCRoomJoin> rooms) async {
assert(
rooms.isNotEmpty,
'The room list should not be empty',
);
await _cacheLock.synchronized(() {
_joinedPreparedRooms = false;
for (final room in rooms) {
final (roomJid, nick) = room;
_mucRoomCache[roomJid] = RoomState(
roomJid: roomJid,
nick: nick,
joined: false,
);
}
});
}
/// Queries the information of a Multi-User Chat room.
///
/// Retrieves the information about the specified MUC room by performing a
@@ -43,7 +144,8 @@ class MUCManager extends XmppManagerBase {
);
return Result(roomInformation);
} catch (e) {
return Result(InvalidDiscoInfoResponse);
logger.warning('Invalid disco information: $e');
return Result(InvalidDiscoInfoResponse());
}
}
@@ -55,11 +157,37 @@ class MUCManager extends XmppManagerBase {
/// if applicable.
Future<Result<bool, MUCError>> joinRoom(
JID roomJid,
String nick,
) async {
String nick, {
int? maxHistoryStanzas,
}) async {
if (nick.isEmpty) {
return Result(NoNicknameSpecified());
}
final completer =
await _cacheLock.synchronized<Completer<Result<bool, MUCError>>>(
() {
_mucRoomCache[roomJid] = RoomState(
roomJid: roomJid,
nick: nick,
joined: false,
);
final completer = Completer<Result<bool, MUCError>>();
_mucRoomJoinCompleter[roomJid] = completer;
return completer;
},
);
await _sendMucJoin(roomJid, nick, maxHistoryStanzas);
return completer.future;
}
Future<void> _sendMucJoin(
JID roomJid,
String nick,
int? maxHistoryStanzas,
) async {
await getAttributes().sendStanza(
StanzaDetails(
Stanza.presence(
@@ -68,17 +196,21 @@ class MUCManager extends XmppManagerBase {
XMLNode.xmlns(
tag: 'x',
xmlns: mucXmlns,
)
children: [
if (maxHistoryStanzas != null)
XMLNode(
tag: 'history',
attributes: {
'maxstanzas': maxHistoryStanzas.toString(),
},
),
],
),
],
),
awaitable: false,
),
);
await _cacheLock.synchronized(
() {
_mucRoomCache[roomJid] = RoomState(roomJid: roomJid, nick: nick);
},
);
return const Result(true);
}
/// Leaves a Multi-User Chat room.
@@ -96,7 +228,7 @@ class MUCManager extends XmppManagerBase {
return nick;
});
if (nick == null) {
return Result(RoomNotJoinedError);
return Result(RoomNotJoinedError());
}
await getAttributes().sendStanza(
StanzaDetails(
@@ -104,8 +236,277 @@ class MUCManager extends XmppManagerBase {
to: roomJid.withResource(nick).toString(),
type: 'unavailable',
),
awaitable: false,
),
);
return const Result(true);
}
Future<RoomState?> getRoomState(JID roomJid) async {
return _cacheLock.synchronized(() => _mucRoomCache[roomJid]);
}
Future<StanzaHandlerData> _onPresence(
Stanza presence,
StanzaHandlerData state,
) async {
if (presence.from == null) {
logger.finest('Ignoring presence as it has no from attribute');
return state;
}
final from = JID.fromString(presence.from!);
final bareFrom = from.toBare();
return _cacheLock.synchronized(() {
logger.finest('Lock aquired for presence from ${presence.from}');
final room = _mucRoomCache[bareFrom];
if (room == null) {
logger.finest('Ignoring presence as it does not belong to a room');
return state;
}
if (from.resource.isEmpty) {
// TODO(Unknown): Handle presence from the room itself.
logger.finest('Ignoring presence as it has no resource');
return state;
}
if (presence.type == 'error') {
final errorTag = presence.firstTag('error')!;
final error = errorTag.firstTagByXmlns(fullStanzaXmlns)!;
Result<bool, MUCError> result;
if (error.tag == 'forbidden') {
result = Result(JoinForbiddenError());
} else {
result = Result(MUCUnspecificError());
}
_mucRoomCache.remove(bareFrom);
_mucRoomJoinCompleter[bareFrom]!.complete(result);
_mucRoomJoinCompleter.remove(bareFrom);
return StanzaHandlerData(
true,
false,
presence,
state.extensions,
);
}
final x = presence.firstTag('x', xmlns: mucUserXmlns)!;
final item = x.firstTag('item')!;
final statuses = x
.findTags('status')
.map((s) => s.attributes['code']! as String)
.toList();
final role = Role.fromString(
item.attributes['role']! as String,
);
final affiliation = Affiliation.fromString(
item.attributes['affiliation']! as String,
);
if (statuses.contains(selfPresenceStatus)) {
if (room.joined) {
if (room.nick != from.resource ||
room.affiliation != affiliation ||
room.role != role) {
// Notify us of the changed data.
getAttributes().sendEvent(
OwnDataChangedEvent(
bareFrom,
from.resource,
affiliation,
role,
),
);
}
}
// Set the data to make sure we're in sync with the MUC.
room
..nick = from.resource
..affiliation = affiliation
..role = role;
logger.finest('Self-presence handled');
return StanzaHandlerData(
true,
false,
presence,
state.extensions,
);
}
if (presence.attributes['type'] == 'unavailable') {
if (role == Role.none) {
// Cannot happen while joining, so we assume we are joined
assert(
room.joined,
'Should not receive unavailable with role="none" while joining',
);
room.members.remove(from.resource);
getAttributes().sendEvent(
MemberLeftEvent(
bareFrom,
from.resource,
),
);
} else if (statuses.contains(nicknameChangedStatus)) {
assert(
room.joined,
'Should not receive nick change while joining',
);
final newNick = item.attributes['nick']! as String;
final member = RoomMember(
newNick,
Affiliation.fromString(
item.attributes['affiliation']! as String,
),
role,
);
// Remove the old member.
room.members.remove(from.resource);
// Add the "new" member".
room.members[newNick] = member;
// Trigger an event.
getAttributes().sendEvent(
MemberChangedNickEvent(
bareFrom,
from.resource,
newNick,
),
);
}
} else {
final member = RoomMember(
from.resource,
Affiliation.fromString(
item.attributes['affiliation']! as String,
),
role,
);
logger.finest('Got presence from ${from.resource} in $bareFrom');
if (room.joined) {
if (room.members.containsKey(from.resource)) {
getAttributes().sendEvent(
MemberChangedEvent(
bareFrom,
member,
),
);
} else {
getAttributes().sendEvent(
MemberJoinedEvent(
bareFrom,
member,
),
);
}
}
room.members[from.resource] = member;
logger.finest('${from.resource} added to the member list');
}
logger.finest('Ran through');
return StanzaHandlerData(
true,
false,
presence,
state.extensions,
);
});
}
Future<StanzaHandlerData> _onMessageSent(
Stanza message,
StanzaHandlerData state,
) async {
if (message.to == null) {
return state;
}
final toJid = JID.fromString(message.to!);
return _cacheLock.synchronized(() {
if (!_mucRoomCache.containsKey(toJid)) {
return state;
}
_mucRoomCache[toJid]!.pendingMessages.add(
(message.id!, state.extensions.get<StableIdData>()?.originId),
);
return state;
});
}
Future<StanzaHandlerData> _onMessage(
Stanza message,
StanzaHandlerData state,
) async {
final fromJid = JID.fromString(message.from!);
final roomJid = fromJid.toBare();
return _cacheLock.synchronized(() {
logger.finest('Lock aquired for message from ${message.from}');
final roomState = _mucRoomCache[roomJid];
if (roomState == null) {
return state;
}
if (message.type == 'groupchat' && message.firstTag('subject') != null) {
// The room subject marks the end of the join flow.
if (!roomState.joined) {
// Mark the room as joined.
_mucRoomCache[roomJid]!.joined = true;
_mucRoomJoinCompleter[roomJid]!.complete(
const Result(true),
);
_mucRoomJoinCompleter.remove(roomJid);
logger.finest('$roomJid is now joined');
}
// TODO(Unknown): Signal the subject?
return StanzaHandlerData(
true,
false,
message,
state.extensions,
);
} else {
if (!roomState.joined) {
// Ignore the discussion history.
return StanzaHandlerData(
true,
false,
message,
state.extensions,
);
}
// Check if this is the message reflection.
if (message.id == null) {
return state;
}
final pending =
(message.id!, state.extensions.get<StableIdData>()?.originId);
if (fromJid.resource == roomState.nick &&
roomState.pendingMessages.contains(pending)) {
// Silently drop the message.
roomState.pendingMessages.remove(pending);
// TODO(Unknown): Maybe send an event stating that we received the reflection.
return StanzaHandlerData(
true,
false,
message,
state.extensions,
);
}
}
return state;
});
}
}

View File

@@ -38,7 +38,7 @@ class VCardManager extends XmppManagerBase {
tagName: 'x',
tagXmlns: vCardTempUpdate,
callback: _onPresence,
)
),
];
@override
@@ -56,27 +56,13 @@ class VCardManager extends XmppManagerBase {
final x = presence.firstTag('x', xmlns: vCardTempUpdate)!;
final hash = x.firstTag('photo')!.innerText();
final from = JID.fromString(presence.from!).toBare();
final lastHash = _lastHash[from];
if (lastHash != hash) {
_lastHash[from.toString()] = hash;
final vcardResult = await requestVCard(from);
if (vcardResult.isType<VCard>()) {
final binval = vcardResult.get<VCard>().photo?.binval;
if (binval != null) {
getAttributes().sendEvent(
VCardAvatarUpdatedEvent(from, binval, hash),
);
} else {
logger.warning('No avatar data found');
}
} else {
logger.warning('Failed to retrieve vCard for $from');
}
}
return state..done = true;
getAttributes().sendEvent(
VCardAvatarUpdatedEvent(
JID.fromString(presence.from!),
hash,
),
);
return state;
}
VCardPhoto? _parseVCardPhoto(XMLNode? node) {
@@ -108,7 +94,7 @@ class VCardManager extends XmppManagerBase {
XMLNode.xmlns(
tag: 'vCard',
xmlns: vCardTempXmlns,
)
),
],
),
encrypted: true,

View File

@@ -38,26 +38,20 @@ class PubSubPublishOptions {
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',
),
]
: [],
if (accessModel != null)
DataFormField(
options: [],
isRequired: false,
values: [accessModel!],
varAttr: 'pubsub#access_model',
),
if (maxItems != null)
DataFormField(
options: [],
isRequired: false,
values: [maxItems!],
varAttr: 'pubsub#max_items',
),
],
).toXml();
}
@@ -87,7 +81,7 @@ class PubSubManager extends XmppManagerBase {
tagName: 'event',
tagXmlns: pubsubEventXmlns,
callback: _onPubsubMessage,
)
),
];
@override
@@ -314,11 +308,11 @@ class PubSubManager extends XmppManagerBase {
children: [
XMLNode(
tag: 'item',
attributes: id != null
? <String, String>{'id': id}
: <String, String>{},
attributes: {
if (id != null) 'id': id,
},
children: [payload],
)
),
],
),
if (pubOptions != null)
@@ -327,7 +321,7 @@ class PubSubManager extends XmppManagerBase {
children: [pubOptions.toXml()],
),
],
)
),
],
),
shouldEncrypt: false,
@@ -422,7 +416,7 @@ class PubSubManager extends XmppManagerBase {
},
),
],
)
),
],
),
shouldEncrypt: false,

View File

@@ -45,7 +45,7 @@ class OOBManager extends XmppManagerBase {
callback: _onMessage,
// Before the message manager
priority: -99,
)
),
];
@override

View File

@@ -69,7 +69,7 @@ class ChatStateManager extends XmppManagerBase {
callback: _onChatStateReceived,
// Before the message handler
priority: -99,
)
),
];
@override

View File

@@ -173,39 +173,20 @@ class EntityCapabilitiesManager extends XmppManagerBase {
});
}
@visibleForTesting
Future<StanzaHandlerData> onPresence(
Stanza stanza,
StanzaHandlerData state,
Future<void> _performQuery(
Stanza presence,
String ver,
String hashFunctionName,
String capabilityNode,
JID from,
) async {
if (stanza.from == null) {
return state;
}
final from = JID.fromString(stanza.from!);
final c = stanza.firstTag('c', xmlns: capsXmlns)!;
final hashFunctionName = c.attributes['hash'] as String?;
final capabilityNode = c.attributes['node'] as String?;
final ver = c.attributes['ver'] as String?;
if (hashFunctionName == null || capabilityNode == null || ver == null) {
return state;
}
// Check if we know of the hash
final isCached =
await _cacheLock.synchronized(() => _capHashCache.containsKey(ver));
if (isCached) {
return state;
}
final dm = getAttributes().getManagerById<DiscoManager>(discoManager)!;
final discoRequest = await dm.discoInfoQuery(
from,
node: capabilityNode,
);
if (discoRequest.isType<StanzaError>()) {
return state;
return;
}
final discoInfo = discoRequest.get<DiscoInfo>();
@@ -220,7 +201,7 @@ class EntityCapabilitiesManager extends XmppManagerBase {
discoInfo,
),
);
return state;
return;
}
// Validate the disco#info result according to XEP-0115 § 5.4
@@ -234,7 +215,7 @@ class EntityCapabilitiesManager extends XmppManagerBase {
logger.warning(
'Malformed disco#info response: More than one equal identity',
);
return state;
return;
}
}
@@ -245,7 +226,7 @@ class EntityCapabilitiesManager extends XmppManagerBase {
logger.warning(
'Malformed disco#info response: More than one equal feature',
);
return state;
return;
}
}
@@ -273,7 +254,7 @@ class EntityCapabilitiesManager extends XmppManagerBase {
logger.warning(
'Malformed disco#info response: Extended Info FORM_TYPE contains more than one value(s) of different value.',
);
return state;
return;
}
}
@@ -288,7 +269,7 @@ class EntityCapabilitiesManager extends XmppManagerBase {
logger.warning(
'Malformed disco#info response: More than one Extended Disco Info forms with the same FORM_TYPE value',
);
return state;
return;
}
// Check if the field type is hidden
@@ -325,7 +306,43 @@ class EntityCapabilitiesManager extends XmppManagerBase {
'Capability hash mismatch from $from: Received "$ver", expected "$computedCapabilityHash".',
);
}
}
@visibleForTesting
Future<StanzaHandlerData> onPresence(
Stanza stanza,
StanzaHandlerData state,
) async {
if (stanza.from == null) {
return state;
}
final from = JID.fromString(stanza.from!);
final c = stanza.firstTag('c', xmlns: capsXmlns)!;
final hashFunctionName = c.attributes['hash'] as String?;
final capabilityNode = c.attributes['node'] as String?;
final ver = c.attributes['ver'] as String?;
if (hashFunctionName == null || capabilityNode == null || ver == null) {
return state;
}
// Check if we know of the hash
final isCached =
await _cacheLock.synchronized(() => _capHashCache.containsKey(ver));
if (isCached) {
return state;
}
unawaited(
_performQuery(
stanza,
ver,
hashFunctionName,
capabilityNode,
from,
),
);
return state;
}

View File

@@ -66,7 +66,7 @@ class MessageDeliveryReceiptManager extends XmppManagerBase {
callback: _onDeliveryRequestReceived,
// Before the message handler
priority: -99,
)
),
];
@override

View File

@@ -27,7 +27,7 @@ class BlockingManager extends XmppManagerBase {
tagName: 'block',
tagXmlns: blockingXmlns,
callback: _blockPush,
)
),
];
@override
@@ -107,10 +107,12 @@ class BlockingManager extends XmppManagerBase {
children: items.map((item) {
return XMLNode(
tag: 'item',
attributes: <String, String>{'jid': item},
attributes: {
'jid': item,
},
);
}).toList(),
)
),
],
),
),
@@ -128,7 +130,7 @@ class BlockingManager extends XmppManagerBase {
XMLNode.xmlns(
tag: 'unblock',
xmlns: blockingXmlns,
)
),
],
),
),
@@ -152,11 +154,13 @@ class BlockingManager extends XmppManagerBase {
.map(
(item) => XMLNode(
tag: 'item',
attributes: <String, String>{'jid': item},
attributes: {
'jid': item,
},
),
)
.toList(),
)
),
],
),
),
@@ -174,7 +178,7 @@ class BlockingManager extends XmppManagerBase {
XMLNode.xmlns(
tag: 'blocklist',
xmlns: blockingXmlns,
)
),
],
),
),

View File

@@ -5,7 +5,10 @@ class StreamManagementEnableNonza extends XMLNode {
StreamManagementEnableNonza()
: super(
tag: 'enable',
attributes: <String, String>{'xmlns': smXmlns, 'resume': 'true'},
attributes: {
'xmlns': smXmlns,
'resume': 'true',
},
);
}
@@ -13,10 +16,10 @@ class StreamManagementResumeNonza extends XMLNode {
StreamManagementResumeNonza(String id, int h)
: super(
tag: 'resume',
attributes: <String, String>{
attributes: {
'xmlns': smXmlns,
'previd': id,
'h': h.toString()
'h': h.toString(),
},
);
}
@@ -25,7 +28,10 @@ class StreamManagementAckNonza extends XMLNode {
StreamManagementAckNonza(int h)
: super(
tag: 'a',
attributes: <String, String>{'xmlns': smXmlns, 'h': h.toString()},
attributes: {
'xmlns': smXmlns,
'h': h.toString(),
},
);
}
@@ -33,7 +39,7 @@ class StreamManagementRequestNonza extends XMLNode {
StreamManagementRequestNonza()
: super(
tag: 'r',
attributes: <String, String>{
attributes: {
'xmlns': smXmlns,
},
);

View File

@@ -75,6 +75,17 @@ class StreamManagementManager extends XmppManagerBase {
return acks;
}
@override
Future<void> onData() async {
// The ack timer does not matter if we are currently in the middle of receiving
// data.
await _ackLock.synchronized(() {
if (_pendingAcks > 0) {
_resetAckTimer();
}
});
}
/// Called when a stanza has been acked to decide whether we should trigger a
/// StanzaAckedEvent.
///
@@ -140,7 +151,7 @@ class StreamManagementManager extends XmppManagerBase {
nonzaTag: 'a',
nonzaXmlns: smXmlns,
callback: _handleAckResponse,
)
),
];
@override
@@ -148,14 +159,14 @@ class StreamManagementManager extends XmppManagerBase {
StanzaHandler(
callback: _onServerStanzaReceived,
priority: 9999,
)
),
];
@override
List<StanzaHandler> getOutgoingPostStanzaHandlers() => [
StanzaHandler(
callback: _onClientStanzaSent,
)
),
];
@override
@@ -225,6 +236,12 @@ class StreamManagementManager extends XmppManagerBase {
_ackTimer = null;
}
/// Resets the ack timer.
void _resetAckTimer() {
_stopAckTimer();
_startAckTimer();
}
@visibleForTesting
Future<void> handleAckTimeout() async {
_stopAckTimer();
@@ -315,8 +332,7 @@ class StreamManagementManager extends XmppManagerBase {
// Reset the timer
if (_pendingAcks > 0) {
_stopAckTimer();
_startAckTimer();
_resetAckTimer();
}
}

View File

@@ -49,7 +49,7 @@ class CarbonsManager extends XmppManagerBase {
tagXmlns: carbonsXmlns,
callback: _onMessageSent,
priority: -98,
)
),
];
@override
@@ -124,7 +124,7 @@ class CarbonsManager extends XmppManagerBase {
XMLNode.xmlns(
tag: 'enable',
xmlns: carbonsXmlns,
)
),
],
),
),
@@ -154,7 +154,7 @@ class CarbonsManager extends XmppManagerBase {
XMLNode.xmlns(
tag: 'disable',
xmlns: carbonsXmlns,
)
),
],
),
),

View File

@@ -40,7 +40,7 @@ class LastMessageCorrectionManager extends XmppManagerBase {
callback: _onMessage,
// Before the message handler
priority: -99,
)
),
];
@override

View File

@@ -100,7 +100,7 @@ class ChatMarkerManager extends XmppManagerBase {
callback: _onMessage,
// Before the message handler
priority: -99,
)
),
];
@override

View File

@@ -81,7 +81,7 @@ class StableIdManager extends XmppManagerBase {
callback: _onMessage,
// Before the MessageManager
priority: -99,
)
),
];
@override
@@ -127,7 +127,13 @@ class StableIdManager extends XmppManagerBase {
TypedMap<StanzaHandlerExtension> extensions,
) {
final data = extensions.get<StableIdData>();
return data != null ? data.toXML() : [];
if (data?.originId != null) {
return [
data!.toOriginIdElement(),
];
}
return [];
}
@override

View File

@@ -161,9 +161,9 @@ class HttpFileUploadManager extends XmppManagerBase {
attributes: {
'filename': filename,
'size': filesize.toString(),
...contentType != null ? {'content-type': contentType} : {}
if (contentType != null) 'content-type': contentType,
},
)
),
],
),
),

View File

@@ -2,10 +2,14 @@ abstract class OmemoError {}
class UnknownOmemoError extends OmemoError {}
class InvalidAffixElementsException with Exception {}
class InvalidAffixElementsException implements Exception {}
/// Internal exception that is returned when the device list cannot be
/// fetched because the returned list is empty.
class EmptyDeviceListException implements OmemoError {}
class OmemoNotSupportedForContactException extends OmemoError {}
class EncryptionFailedException with Exception {}
class EncryptionFailedException implements Exception {}
class InvalidEnvelopePayloadException with Exception {}
class InvalidEnvelopePayloadException implements Exception {}

View File

@@ -535,7 +535,10 @@ class OmemoManager extends XmppManagerBase {
final pm = getAttributes().getManagerById<PubSubManager>(pubsubManager)!;
final result = await pm.getItems(jid.toBare(), omemoDevicesXmlns);
if (result.isType<PubSubError>()) return Result(UnknownOmemoError());
return Result(result.get<List<PubSubItem>>().first.payload);
final itemList = result.get<List<PubSubItem>>();
if (itemList.isEmpty) return Result(EmptyDeviceListException());
return Result(itemList.first.payload);
}
/// Retrieves the OMEMO device list from [jid].

View File

@@ -82,7 +82,7 @@ class SIMSManager extends XmppManagerBase {
tagXmlns: referenceXmlns,
// Before the message handler
priority: -99,
)
),
];
@override

View File

@@ -31,7 +31,7 @@ class MessageRetractionManager extends XmppManagerBase {
callback: _onMessage,
// Before the MessageManager
priority: -99,
)
),
];
@override

View File

@@ -133,7 +133,7 @@ class SFSManager extends XmppManagerBase {
callback: _onMessage,
// Before the message handler
priority: -98,
)
),
];
@override

View File

@@ -96,7 +96,7 @@ class MessageRepliesManager extends XmppManagerBase {
callback: _onMessage,
// Before the message handler
priority: -99,
)
),
];
@override
@@ -107,40 +107,41 @@ class MessageRepliesManager extends XmppManagerBase {
TypedMap<StanzaHandlerExtension> extensions,
) {
final data = extensions.get<ReplyData>();
return data != null
? [
XMLNode.xmlns(
tag: 'reply',
xmlns: replyXmlns,
attributes: {
// The to attribute is optional
if (data.jid != null) 'to': data.jid!.toString(),
if (data == null) {
return [];
}
return [
XMLNode.xmlns(
tag: 'reply',
xmlns: replyXmlns,
attributes: {
// The to attribute is optional
if (data.jid != null) 'to': data.jid!.toString(),
'id': data.id,
'id': data.id,
},
),
if (data.body != null)
XMLNode(
tag: 'body',
text: data.body,
),
if (data.body != null)
XMLNode.xmlns(
tag: 'fallback',
xmlns: fallbackIndicationXmlns,
attributes: {'for': replyXmlns},
children: [
XMLNode(
tag: 'body',
attributes: {
'start': data.start!.toString(),
'end': data.end!.toString(),
},
),
if (data.body != null)
XMLNode(
tag: 'body',
text: data.body,
),
if (data.body != null)
XMLNode.xmlns(
tag: 'fallback',
xmlns: fallbackXmlns,
attributes: {'for': replyXmlns},
children: [
XMLNode(
tag: 'body',
attributes: {
'start': data.start!.toString(),
'end': data.end!.toString(),
},
),
],
),
]
: [];
],
),
];
}
Future<StanzaHandlerData> _onMessage(
@@ -154,7 +155,8 @@ class MessageRepliesManager extends XmppManagerBase {
int? end;
// TODO(Unknown): Maybe extend firstTag to also look for attributes
final fallback = stanza.firstTag('fallback', xmlns: fallbackXmlns);
final fallback =
stanza.firstTag('fallback', xmlns: fallbackIndicationXmlns);
if (fallback != null) {
final body = fallback.firstTag('body')!;
start = int.parse(body.attributes['start']! as String);

View File

@@ -274,16 +274,16 @@
<xmpp:version>0.2.0</xmpp:version>
</xmpp:SupportedXep>
</implements>
<!-- Non-Standard (Proto) XEPs -->
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/inbox/xep-fast.html" />
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0484.html" />
<xmpp:status>partial</xmpp:status>
<xmpp:version>0.0.1</xmpp:version>
<xmpp:version>0.1.1</xmpp:version>
<xmpp:note xml:lang="en">Invalidation is never requested</xmpp:note>
</xmpp:SupportedXep>
</implements>
<!-- Non-Standard (Proto) XEPs -->
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://codeberg.org/moxxy/custom-xeps/src/branch/master/xep-xxxx-file-upload-notification.md"/>

View File

@@ -5,28 +5,28 @@ homepage: https://codeberg.org/moxxy/moxxmpp
publish_to: https://git.polynom.me/api/packages/Moxxy/pub
environment:
sdk: '>=2.17.5 <3.0.0'
sdk: ">=3.0.0 <4.0.0"
dependencies:
collection: ^1.16.0
cryptography: ^2.0.5
collection: ^1.18.0
cryptography: ^2.7.0
hex: ^0.2.0
json_serializable: ^6.3.1
logging: ^1.0.2
meta: ^1.7.0
json_serializable: ^6.8.0
logging: ^1.2.0
meta: ^1.15.0
moxlib:
hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: ^0.2.0
hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: ^0.2.0
omemo_dart:
hosted: https://git.polynom.me/api/packages/PapaTutuWawa/pub
version: ^0.5.1
version: ^0.6.0
random_string: ^2.3.1
saslprep: ^1.0.2
synchronized: ^3.0.0+2
uuid: ^3.0.5
xml: ^6.1.0
saslprep: ^1.0.3
synchronized: ^3.3.0+3
uuid: ^3.0.7
xml: ^6.5.0
dev_dependencies:
build_runner: ^2.1.11
test: ^1.16.0
very_good_analysis: ^3.0.1
build_runner: ^2.4.12
test: ^1.25.8
very_good_analysis: ^6.0.0

View File

@@ -2,11 +2,12 @@ import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxmpp/src/awaiter.dart';
import 'package:test/test.dart';
void main() {
const bareJid = JID('moxxmpp', 'server3.example', '');
const bareJid = 'user4@example.org';
String getBareJidCallback() => bareJid;
void main() {
test('Test awaiting an awaited stanza with a from attribute', () async {
final awaiter = StanzaAwaiter();
final awaiter = StanzaAwaiter(getBareJidCallback);
// "Send" a stanza
final future = await awaiter.addPending(
@@ -20,14 +21,12 @@ void main() {
XMLNode.fromString(
'<iq from="user3@server.example" id="abc123" type="result" />',
),
bareJid,
);
expect(result1, false);
final result2 = await awaiter.onData(
XMLNode.fromString(
'<iq from="user1@server.example" id="lol" type="result" />',
),
bareJid,
);
expect(result2, false);
@@ -37,22 +36,20 @@ void main() {
);
final result3 = await awaiter.onData(
stanza,
bareJid,
);
expect(result3, true);
expect(await future, stanza);
});
test('Test awaiting an awaited stanza without a from attribute', () async {
final awaiter = StanzaAwaiter();
final awaiter = StanzaAwaiter(getBareJidCallback);
// "Send" a stanza
final future = await awaiter.addPending(bareJid.toString(), 'abc123', 'iq');
final future = await awaiter.addPending(null, 'abc123', 'iq');
// Receive the wrong answer
final result1 = await awaiter.onData(
XMLNode.fromString('<iq id="lol" type="result" />'),
bareJid,
);
expect(result1, false);
@@ -60,23 +57,21 @@ void main() {
final stanza = XMLNode.fromString('<iq id="abc123" type="result" />');
final result2 = await awaiter.onData(
stanza,
bareJid,
);
expect(result2, true);
expect(await future, stanza);
});
test('Test awaiting a stanza that was already awaited', () async {
final awaiter = StanzaAwaiter();
final awaiter = StanzaAwaiter(getBareJidCallback);
// "Send" a stanza
final future = await awaiter.addPending(bareJid.toString(), 'abc123', 'iq');
final future = await awaiter.addPending(null, 'abc123', 'iq');
// Receive the correct answer
final stanza = XMLNode.fromString('<iq id="abc123" type="result" />');
final result1 = await awaiter.onData(
stanza,
bareJid,
);
expect(result1, true);
expect(await future, stanza);
@@ -84,31 +79,55 @@ void main() {
// Receive it again
final result2 = await awaiter.onData(
stanza,
bareJid,
);
expect(result2, false);
});
test('Test ignoring a stanza that has the wrong tag', () async {
final awaiter = StanzaAwaiter();
final awaiter = StanzaAwaiter(getBareJidCallback);
// "Send" a stanza
final future = await awaiter.addPending(bareJid.toString(), 'abc123', 'iq');
final future = await awaiter.addPending(null, 'abc123', 'iq');
// Receive the wrong answer
final stanza = XMLNode.fromString('<iq id="abc123" type="result" />');
final result1 = await awaiter.onData(
XMLNode.fromString('<message id="abc123" type="result" />'),
bareJid,
);
expect(result1, false);
// Receive the correct answer
final result2 = await awaiter.onData(
stanza,
bareJid,
);
expect(result2, true);
expect(await future, stanza);
});
test('Sending a stanza to our bare JID', () async {
final awaiter = StanzaAwaiter(getBareJidCallback);
// "Send" a stanza
final future = await awaiter.addPending(bareJid, 'abc123', 'iq');
// Receive the response.
final stanza = XMLNode.fromString('<iq id="abc123" type="result" />');
await awaiter.onData(stanza);
expect(await future, stanza);
});
test(
'Sending a stanza to our bare JID and receiving stanza with a from attribute',
() async {
final awaiter = StanzaAwaiter(getBareJidCallback);
// "Send" a stanza
final future = await awaiter.addPending(bareJid, 'abc123', 'iq');
// Receive the response.
final stanza =
XMLNode.fromString('<iq from="$bareJid" id="abc123" type="result" />');
await awaiter.onData(stanza);
expect(await future, stanza);
});
}

View File

@@ -89,6 +89,7 @@ List<ExpectationBase> buildAuthenticatedPlay(ConnectionSettings settings) {
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/>
</bind>
<ver xmlns='urn:xmpp:features:rosterver'/>
</stream:features>
''',
),

View File

@@ -0,0 +1,53 @@
import 'package:moxxmpp/moxxmpp.dart';
import 'package:test/test.dart';
import 'helpers/logging.dart';
import 'helpers/xmpp.dart';
void main() {
initLogger();
test('Test a versioned roster fetch returning an empty iq', () async {
final sm = TestingRosterStateManager('ver14', []);
final rm = RosterManager(sm);
final cs = ConnectionSettings(
jid: JID.fromString('user@example.org'),
password: 'abc123',
);
final socket = StubTCPSocket([
...buildAuthenticatedPlay(cs),
StanzaExpectation(
'<iq xmlns="jabber:client" id="r1h3vzp7" type="get"><query xmlns="jabber:iq:roster" ver="ver14"/></iq>',
'<iq xmlns="jabber:client" id="r1h3vzp7" type="result" />',
ignoreId: true,
adjustId: true,
),
]);
final conn = XmppConnection(
TestingReconnectionPolicy(),
AlwaysConnectedConnectivityManager(),
ClientToServerNegotiator(),
socket,
)..connectionSettings = cs;
await conn.registerManagers([
rm,
PresenceManager(),
]);
await conn.registerFeatureNegotiators([
SaslPlainNegotiator(),
ResourceBindingNegotiator(),
RosterFeatureNegotiator(),
]);
// Connect
await conn.connect(
shouldReconnect: false,
waitUntilLogin: true,
);
// Request the roster
final rawResult = await rm.requestRoster();
expect(rawResult.isType<RosterRequestResult>(), true);
final result = rawResult.get<RosterRequestResult>();
expect(result.items.isEmpty, true);
});
}

View File

@@ -14,9 +14,9 @@ final scramSha1StreamFeatures = XMLNode(
XMLNode(
tag: 'mechanism',
text: 'SCRAM-SHA-1',
)
),
],
)
),
],
);
final scramSha256StreamFeatures = XMLNode(
@@ -29,9 +29,9 @@ final scramSha256StreamFeatures = XMLNode(
XMLNode(
tag: 'mechanism',
text: 'SCRAM-SHA-256',
)
),
],
)
),
],
);

View File

@@ -171,7 +171,7 @@ void main() {
),
tagName: '3',
priority: 50,
)
),
]..sort(stanzaHandlerSortComparator);
expect(handlerList[0].tagName, '1');

View File

@@ -0,0 +1,874 @@
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:test/test.dart';
import '../helpers/logging.dart';
import '../helpers/xmpp.dart';
void main() {
initLogger();
test(
'Test connecting to MUCs after a reconnection without stream resumption',
() async {
final fakeSocket = StubTCPSocket([
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
xmlns:stream="http://etherx.jabber.org/streams"
from="test.server"
xml:lang="en">
<stream:features xmlns="http://etherx.jabber.org/streams">
<mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl">
<mechanism>PLAIN</mechanism>
</mechanisms>
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/>
</bind>
</stream:features>''',
),
StringExpectation(
"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>AHBvbHlub21kaXZpc2lvbgBhYWFh</auth>",
'<success xmlns="urn:ietf:params:xml:ns:xmpp-sasl" />',
),
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
xmlns:stream="http://etherx.jabber.org/streams"
from="test.server"
xml:lang="en">
<stream:features xmlns="http://etherx.jabber.org/streams">
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/>
</bind>
<session xmlns="urn:ietf:params:xml:ns:xmpp-session">
<optional/>
</session>
<csi xmlns="urn:xmpp:csi:0"/>
<sm xmlns="urn:xmpp:sm:3"/>
</stream:features>
''',
),
StanzaExpectation(
'<iq xmlns="jabber:client" type="set" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"/></iq>',
'<iq xmlns="jabber:client" type="result" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><jid>polynomdivision@test.server/MU29eEZn</jid></bind></iq>',
ignoreId: true,
),
StanzaExpectation(
'<presence to="channel@muc.example.org/test" xmlns="jabber:client"><x xmlns="http://jabber.org/protocol/muc"><history maxstanzas="0"/></x></presence>',
'<message from="channel@muc.example.org" type="groupchat" xmlns="jabber:client"><subject/></message>',
ignoreId: true,
),
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
xmlns:stream="http://etherx.jabber.org/streams"
from="test.server"
xml:lang="en">
<stream:features xmlns="http://etherx.jabber.org/streams">
<mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl">
<mechanism>PLAIN</mechanism>
</mechanisms>
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/>
</bind>
</stream:features>''',
),
StringExpectation(
"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>AHBvbHlub21kaXZpc2lvbgBhYWFh</auth>",
'<success xmlns="urn:ietf:params:xml:ns:xmpp-sasl" />',
),
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
xmlns:stream="http://etherx.jabber.org/streams"
from="test.server"
xml:lang="en">
<stream:features xmlns="http://etherx.jabber.org/streams">
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/>
</bind>
<session xmlns="urn:ietf:params:xml:ns:xmpp-session">
<optional/>
</session>
<csi xmlns="urn:xmpp:csi:0"/>
<sm xmlns="urn:xmpp:sm:3"/>
</stream:features>
''',
),
StanzaExpectation(
'<iq xmlns="jabber:client" type="set" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"/></iq>',
'<iq xmlns="jabber:client" type="result" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><jid>polynomdivision@test.server/MU29eEZn</jid></bind></iq>',
ignoreId: true,
),
StanzaExpectation(
'<presence to="channel@muc.example.org/test" xmlns="jabber:client"><x xmlns="http://jabber.org/protocol/muc"><history maxstanzas="0"/></x></presence>',
'<message from="channel@muc.example.org" type="groupchat" xmlns="jabber:client"><subject/></message>',
ignoreId: true,
),
]);
final conn = XmppConnection(
TestingSleepReconnectionPolicy(1),
AlwaysConnectedConnectivityManager(),
ClientToServerNegotiator(),
fakeSocket,
)
..connectionSettings = ConnectionSettings(
jid: JID.fromString('polynomdivision@test.server'),
password: 'aaaa',
)
..setResource('test-resource', triggerEvent: false);
await conn.registerManagers([
DiscoManager([]),
MUCManager(),
]);
await conn.registerFeatureNegotiators([
SaslPlainNegotiator(),
ResourceBindingNegotiator(),
]);
await conn.connect(
waitUntilLogin: true,
shouldReconnect: true,
);
// Join a groupchat
final joinResult =
await conn.getManagerById<MUCManager>(mucManager)!.joinRoom(
JID.fromString('channel@muc.example.org'),
'test',
maxHistoryStanzas: 0,
);
expect(joinResult.isType<bool>(), true);
expect(joinResult.get<bool>(), true);
// Trigger a reconnection reason.
Logger('Test').info('Injecting socket fault');
fakeSocket.injectSocketFault();
await Future<void>.delayed(const Duration(seconds: 4));
expect(fakeSocket.getState(), 10);
},
);
test(
'Test joining a MUC with other members',
() async {
final fakeSocket = StubTCPSocket([
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
xmlns:stream="http://etherx.jabber.org/streams"
from="test.server"
xml:lang="en">
<stream:features xmlns="http://etherx.jabber.org/streams">
<mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl">
<mechanism>PLAIN</mechanism>
</mechanisms>
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/>
</bind>
</stream:features>''',
),
StringExpectation(
"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>AHBvbHlub21kaXZpc2lvbgBhYWFh</auth>",
'<success xmlns="urn:ietf:params:xml:ns:xmpp-sasl" />',
),
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
xmlns:stream="http://etherx.jabber.org/streams"
from="test.server"
xml:lang="en">
<stream:features xmlns="http://etherx.jabber.org/streams">
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/>
</bind>
<session xmlns="urn:ietf:params:xml:ns:xmpp-session">
<optional/>
</session>
<csi xmlns="urn:xmpp:csi:0"/>
<sm xmlns="urn:xmpp:sm:3"/>
</stream:features>
''',
),
StanzaExpectation(
'<iq xmlns="jabber:client" type="set" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"/></iq>',
'<iq xmlns="jabber:client" type="result" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><jid>polynomdivision@test.server/MU29eEZn</jid></bind></iq>',
ignoreId: true,
),
StanzaExpectation(
'<presence to="channel@muc.example.org/test" xmlns="jabber:client"><x xmlns="http://jabber.org/protocol/muc"><history maxstanzas="0"/></x></presence>',
'',
ignoreId: true,
),
]);
final conn = XmppConnection(
TestingSleepReconnectionPolicy(1),
AlwaysConnectedConnectivityManager(),
ClientToServerNegotiator(),
fakeSocket,
)
..connectionSettings = ConnectionSettings(
jid: JID.fromString('polynomdivision@test.server'),
password: 'aaaa',
)
..setResource('test-resource', triggerEvent: false);
await conn.registerManagers([
DiscoManager([]),
MUCManager(),
]);
await conn.registerFeatureNegotiators([
SaslPlainNegotiator(),
ResourceBindingNegotiator(),
]);
await conn.connect(
waitUntilLogin: true,
shouldReconnect: false,
);
// Join a groupchat
final roomJid = JID.fromString('channel@muc.example.org');
final joinResult = conn.getManagerById<MUCManager>(mucManager)!.joinRoom(
roomJid,
'test',
maxHistoryStanzas: 0,
);
await Future<void>.delayed(const Duration(seconds: 1));
fakeSocket
..injectRawXml(
'''
<presence
from='channel@muc.example.org/firstwitch'
id='3DCB0401-D7CF-4E31-BE05-EDF8D057BFBD'>
<x xmlns='http://jabber.org/protocol/muc#user'>
<item affiliation='owner' role='moderator'/>
</x>
</presence>
''',
)
..injectRawXml(
'''
<presence
from='channel@muc.example.org/secondwitch'
id='C2CD9EE3-8421-431E-854A-A2AD0CE2E23D'>
<x xmlns='http://jabber.org/protocol/muc#user'>
<item affiliation='admin' role='moderator'/>
</x>
</presence>
''',
)
..injectRawXml(
'''
<presence
from='channel@muc.example.org/test'
id='C2CD9EE3-8421-431E-854A-A2AD0CE2E23E'>
<x xmlns='http://jabber.org/protocol/muc#user'>
<item affiliation='member' role='none'/>
<status code='110' />
</x>
</presence>
''',
)
..injectRawXml(
'''
<message from="channel@muc.example.org" type="groupchat" xmlns="jabber:client">
<subject/>
</message>
''',
);
await joinResult;
expect(fakeSocket.getState(), 5);
final room = (await conn
.getManagerById<MUCManager>(mucManager)!
.getRoomState(roomJid))!;
expect(room.joined, true);
expect(
room.members.length,
2,
);
expect(
room.members['test'],
null,
);
expect(
room.members['secondwitch']!.role,
Role.moderator,
);
expect(
room.members['secondwitch']!.affiliation,
Affiliation.admin,
);
expect(
room.members['firstwitch']!.role,
Role.moderator,
);
expect(
room.members['firstwitch']!.affiliation,
Affiliation.owner,
);
},
);
test(
'Testing a user joining a room',
() async {
final fakeSocket = StubTCPSocket([
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
xmlns:stream="http://etherx.jabber.org/streams"
from="test.server"
xml:lang="en">
<stream:features xmlns="http://etherx.jabber.org/streams">
<mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl">
<mechanism>PLAIN</mechanism>
</mechanisms>
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/>
</bind>
</stream:features>''',
),
StringExpectation(
"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>AHBvbHlub21kaXZpc2lvbgBhYWFh</auth>",
'<success xmlns="urn:ietf:params:xml:ns:xmpp-sasl" />',
),
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
xmlns:stream="http://etherx.jabber.org/streams"
from="test.server"
xml:lang="en">
<stream:features xmlns="http://etherx.jabber.org/streams">
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/>
</bind>
<session xmlns="urn:ietf:params:xml:ns:xmpp-session">
<optional/>
</session>
<csi xmlns="urn:xmpp:csi:0"/>
<sm xmlns="urn:xmpp:sm:3"/>
</stream:features>
''',
),
StanzaExpectation(
'<iq xmlns="jabber:client" type="set" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"/></iq>',
'<iq xmlns="jabber:client" type="result" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><jid>polynomdivision@test.server/MU29eEZn</jid></bind></iq>',
ignoreId: true,
),
StanzaExpectation(
'<presence to="channel@muc.example.org/test" xmlns="jabber:client"><x xmlns="http://jabber.org/protocol/muc"><history maxstanzas="0"/></x></presence>',
'',
ignoreId: true,
),
]);
final conn = XmppConnection(
TestingSleepReconnectionPolicy(1),
AlwaysConnectedConnectivityManager(),
ClientToServerNegotiator(),
fakeSocket,
)
..connectionSettings = ConnectionSettings(
jid: JID.fromString('polynomdivision@test.server'),
password: 'aaaa',
)
..setResource('test-resource', triggerEvent: false);
await conn.registerManagers([
DiscoManager([]),
MUCManager(),
]);
await conn.registerFeatureNegotiators([
SaslPlainNegotiator(),
ResourceBindingNegotiator(),
]);
await conn.connect(
waitUntilLogin: true,
shouldReconnect: false,
);
// Join a groupchat
final roomJid = JID.fromString('channel@muc.example.org');
final joinResult = conn.getManagerById<MUCManager>(mucManager)!.joinRoom(
roomJid,
'test',
maxHistoryStanzas: 0,
);
await Future<void>.delayed(const Duration(seconds: 1));
fakeSocket
..injectRawXml(
'''
<presence
from='channel@muc.example.org/firstwitch'
id='3DCB0401-D7CF-4E31-BE05-EDF8D057BFBD'>
<x xmlns='http://jabber.org/protocol/muc#user'>
<item affiliation='owner' role='moderator'/>
</x>
</presence>
''',
)
..injectRawXml(
'''
<presence
from='channel@muc.example.org/secondwitch'
id='C2CD9EE3-8421-431E-854A-A2AD0CE2E23D'>
<x xmlns='http://jabber.org/protocol/muc#user'>
<item affiliation='admin' role='moderator'/>
</x>
</presence>
''',
)
..injectRawXml(
'''
<presence
from='channel@muc.example.org/test'
id='C2CD9EE3-8421-431E-854A-A2AD0CE2E23E'>
<x xmlns='http://jabber.org/protocol/muc#user'>
<item affiliation='member' role='none'/>
<status code='110' />
</x>
</presence>
''',
)
..injectRawXml(
'''
<message from="channel@muc.example.org" type="groupchat" xmlns="jabber:client">
<subject/>
</message>
''',
);
await joinResult;
final room = (await conn
.getManagerById<MUCManager>(mucManager)!
.getRoomState(roomJid))!;
expect(room.joined, true);
expect(
room.members.length,
2,
);
// Now a new user joins the room.
MemberJoinedEvent? event;
conn.asBroadcastStream().listen((e) {
if (e is MemberJoinedEvent) {
event = e;
}
});
fakeSocket.injectRawXml(
'''
<presence
from='channel@muc.example.org/papatutuwawa'
id='C2CD9EE3-8421-431E-854A-A2AD0CE2E23G'>
<x xmlns='http://jabber.org/protocol/muc#user'>
<item affiliation='admin' role='participant'/>
</x>
</presence>
''',
);
await Future<void>.delayed(const Duration(seconds: 2));
expect(event != null, true);
expect(event!.member.nick, 'papatutuwawa');
expect(event!.member.affiliation, Affiliation.admin);
expect(event!.member.role, Role.participant);
final roomAfterJoin = (await conn
.getManagerById<MUCManager>(mucManager)!
.getRoomState(roomJid))!;
expect(roomAfterJoin.members.length, 3);
},
);
test(
'Testing a user leaving a room',
() async {
final fakeSocket = StubTCPSocket([
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
xmlns:stream="http://etherx.jabber.org/streams"
from="test.server"
xml:lang="en">
<stream:features xmlns="http://etherx.jabber.org/streams">
<mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl">
<mechanism>PLAIN</mechanism>
</mechanisms>
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/>
</bind>
</stream:features>''',
),
StringExpectation(
"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>AHBvbHlub21kaXZpc2lvbgBhYWFh</auth>",
'<success xmlns="urn:ietf:params:xml:ns:xmpp-sasl" />',
),
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
xmlns:stream="http://etherx.jabber.org/streams"
from="test.server"
xml:lang="en">
<stream:features xmlns="http://etherx.jabber.org/streams">
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/>
</bind>
<session xmlns="urn:ietf:params:xml:ns:xmpp-session">
<optional/>
</session>
<csi xmlns="urn:xmpp:csi:0"/>
<sm xmlns="urn:xmpp:sm:3"/>
</stream:features>
''',
),
StanzaExpectation(
'<iq xmlns="jabber:client" type="set" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"/></iq>',
'<iq xmlns="jabber:client" type="result" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><jid>polynomdivision@test.server/MU29eEZn</jid></bind></iq>',
ignoreId: true,
),
StanzaExpectation(
'<presence to="channel@muc.example.org/test" xmlns="jabber:client"><x xmlns="http://jabber.org/protocol/muc"><history maxstanzas="0"/></x></presence>',
'',
ignoreId: true,
),
]);
final conn = XmppConnection(
TestingSleepReconnectionPolicy(1),
AlwaysConnectedConnectivityManager(),
ClientToServerNegotiator(),
fakeSocket,
)
..connectionSettings = ConnectionSettings(
jid: JID.fromString('polynomdivision@test.server'),
password: 'aaaa',
)
..setResource('test-resource', triggerEvent: false);
await conn.registerManagers([
DiscoManager([]),
MUCManager(),
]);
await conn.registerFeatureNegotiators([
SaslPlainNegotiator(),
ResourceBindingNegotiator(),
]);
await conn.connect(
waitUntilLogin: true,
shouldReconnect: false,
);
// Join a groupchat
final roomJid = JID.fromString('channel@muc.example.org');
final joinResult = conn.getManagerById<MUCManager>(mucManager)!.joinRoom(
roomJid,
'test',
maxHistoryStanzas: 0,
);
await Future<void>.delayed(const Duration(seconds: 1));
fakeSocket
..injectRawXml(
'''
<presence
from='channel@muc.example.org/firstwitch'
id='3DCB0401-D7CF-4E31-BE05-EDF8D057BFBD'>
<x xmlns='http://jabber.org/protocol/muc#user'>
<item affiliation='owner' role='moderator'/>
</x>
</presence>
''',
)
..injectRawXml(
'''
<presence
from='channel@muc.example.org/secondwitch'
id='C2CD9EE3-8421-431E-854A-A2AD0CE2E23D'>
<x xmlns='http://jabber.org/protocol/muc#user'>
<item affiliation='admin' role='moderator'/>
</x>
</presence>
''',
)
..injectRawXml(
'''
<presence
from='channel@muc.example.org/test'
id='C2CD9EE3-8421-431E-854A-A2AD0CE2E23E'>
<x xmlns='http://jabber.org/protocol/muc#user'>
<item affiliation='member' role='none'/>
<status code='110' />
</x>
</presence>
''',
)
..injectRawXml(
'''
<message from="channel@muc.example.org" type="groupchat" xmlns="jabber:client">
<subject/>
</message>
''',
);
await joinResult;
final room = (await conn
.getManagerById<MUCManager>(mucManager)!
.getRoomState(roomJid))!;
expect(room.joined, true);
expect(
room.members.length,
2,
);
// Now a user leaves the room.
MemberLeftEvent? event;
conn.asBroadcastStream().listen((e) {
if (e is MemberLeftEvent) {
event = e;
}
});
fakeSocket.injectRawXml(
'''
<presence
from='channel@muc.example.org/secondwitch'
id='C2CD9EE3-8421-431E-854A-A2AD0CE2E23G'
type='unavailable'>
<x xmlns='http://jabber.org/protocol/muc#user'>
<item affiliation='admin' role='none'/>
</x>
</presence>
''',
);
await Future<void>.delayed(const Duration(seconds: 2));
expect(event != null, true);
expect(event!.nick, 'secondwitch');
final roomAfterLeave = (await conn
.getManagerById<MUCManager>(mucManager)!
.getRoomState(roomJid))!;
expect(roomAfterLeave.members.length, 1);
},
);
test(
'Test a user changing their nick name',
() async {
final fakeSocket = StubTCPSocket([
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
xmlns:stream="http://etherx.jabber.org/streams"
from="test.server"
xml:lang="en">
<stream:features xmlns="http://etherx.jabber.org/streams">
<mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl">
<mechanism>PLAIN</mechanism>
</mechanisms>
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/>
</bind>
</stream:features>''',
),
StringExpectation(
"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>AHBvbHlub21kaXZpc2lvbgBhYWFh</auth>",
'<success xmlns="urn:ietf:params:xml:ns:xmpp-sasl" />',
),
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
xmlns:stream="http://etherx.jabber.org/streams"
from="test.server"
xml:lang="en">
<stream:features xmlns="http://etherx.jabber.org/streams">
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/>
</bind>
<session xmlns="urn:ietf:params:xml:ns:xmpp-session">
<optional/>
</session>
<csi xmlns="urn:xmpp:csi:0"/>
<sm xmlns="urn:xmpp:sm:3"/>
</stream:features>
''',
),
StanzaExpectation(
'<iq xmlns="jabber:client" type="set" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"/></iq>',
'<iq xmlns="jabber:client" type="result" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><jid>polynomdivision@test.server/MU29eEZn</jid></bind></iq>',
ignoreId: true,
),
StanzaExpectation(
'<presence to="channel@muc.example.org/test" xmlns="jabber:client"><x xmlns="http://jabber.org/protocol/muc"><history maxstanzas="0"/></x></presence>',
'',
ignoreId: true,
),
]);
final conn = XmppConnection(
TestingSleepReconnectionPolicy(1),
AlwaysConnectedConnectivityManager(),
ClientToServerNegotiator(),
fakeSocket,
)
..connectionSettings = ConnectionSettings(
jid: JID.fromString('polynomdivision@test.server'),
password: 'aaaa',
)
..setResource('test-resource', triggerEvent: false);
await conn.registerManagers([
DiscoManager([]),
MUCManager(),
]);
await conn.registerFeatureNegotiators([
SaslPlainNegotiator(),
ResourceBindingNegotiator(),
]);
await conn.connect(
waitUntilLogin: true,
shouldReconnect: false,
);
// Join a groupchat
final roomJid = JID.fromString('channel@muc.example.org');
final joinResult = conn.getManagerById<MUCManager>(mucManager)!.joinRoom(
roomJid,
'test',
maxHistoryStanzas: 0,
);
await Future<void>.delayed(const Duration(seconds: 1));
fakeSocket
..injectRawXml(
'''
<presence
from='channel@muc.example.org/firstwitch'
id='3DCB0401-D7CF-4E31-BE05-EDF8D057BFBD'>
<x xmlns='http://jabber.org/protocol/muc#user'>
<item affiliation='owner' role='moderator'/>
</x>
</presence>
''',
)
..injectRawXml(
'''
<presence
from='channel@muc.example.org/secondwitch'
id='C2CD9EE3-8421-431E-854A-A2AD0CE2E23D'>
<x xmlns='http://jabber.org/protocol/muc#user'>
<item affiliation='admin' role='moderator'/>
</x>
</presence>
''',
)
..injectRawXml(
'''
<presence
from='channel@muc.example.org/test'
id='C2CD9EE3-8421-431E-854A-A2AD0CE2E23E'>
<x xmlns='http://jabber.org/protocol/muc#user'>
<item affiliation='member' role='none'/>
<status code='110' />
</x>
</presence>
''',
)
..injectRawXml(
'''
<message from="channel@muc.example.org" type="groupchat" xmlns="jabber:client">
<subject/>
</message>
''',
);
await joinResult;
final room = (await conn
.getManagerById<MUCManager>(mucManager)!
.getRoomState(roomJid))!;
expect(room.joined, true);
expect(
room.members.length,
2,
);
// Now a new user changes their nick.
MemberChangedNickEvent? event;
conn.asBroadcastStream().listen((e) {
if (e is MemberChangedNickEvent) {
event = e;
}
});
fakeSocket.injectRawXml(
'''
<presence
from='channel@muc.example.org/firstwitch'
id='3DCB0401-D7CF-4E31-BE05-EDF8D057BFBD'
type='unavailable'>
<x xmlns='http://jabber.org/protocol/muc#user'>
<item affiliation='owner' role='moderator' nick='papatutuwawa'/>
<status code='303'/>
</x>
</presence>
''',
);
await Future<void>.delayed(const Duration(seconds: 2));
expect(event != null, true);
expect(event!.oldNick, 'firstwitch');
expect(event!.newNick, 'papatutuwawa');
final roomAfterChange = (await conn
.getManagerById<MUCManager>(mucManager)!
.getRoomState(roomJid))!;
expect(roomAfterChange.members.length, 2);
expect(roomAfterChange.members['firstwitch'], null);
expect(roomAfterChange.members['papatutuwawa'] != null, true);
},
);
}

View File

@@ -158,7 +158,7 @@ void main() {
</iq>''',
ignoreId: true,
adjustId: true,
)
),
],
);

View File

@@ -69,7 +69,7 @@ class StubbedDiscoManager extends DiscoManager {
isRequired: false,
varAttr: 'FORM_TYPE',
type: 'hidden',
)
),
],
reported: [],
items: [],
@@ -153,14 +153,14 @@ void main() {
'http://jabber.org/protocol/caps',
'http://jabber.org/protocol/disco#info',
'http://jabber.org/protocol/disco#items',
'http://jabber.org/protocol/muc'
'http://jabber.org/protocol/muc',
],
const [
Identity(
category: 'client',
type: 'pc',
name: 'Exodus 0.9.1',
)
),
],
const [],
null,
@@ -179,7 +179,7 @@ void main() {
'http://jabber.org/protocol/caps',
'http://jabber.org/protocol/disco#info',
'http://jabber.org/protocol/disco#items',
'http://jabber.org/protocol/muc'
'http://jabber.org/protocol/muc',
],
const [
Identity(
@@ -295,14 +295,14 @@ void main() {
'urn:xmpp:message-correct:0',
'urn:xmpp:ping',
'urn:xmpp:receipts',
'urn:xmpp:time'
'urn:xmpp:time',
],
const [
Identity(
category: 'client',
type: 'phone',
name: 'Conversations',
)
),
],
const [],
null,
@@ -343,6 +343,7 @@ void main() {
stanza,
StanzaHandlerData(false, false, stanza, TypedMap()),
);
await Future<void>.delayed(const Duration(seconds: 2));
expect(
await manager.getCachedDiscoInfoFromJid(aliceJid) != null,
@@ -513,6 +514,7 @@ void main() {
stanza,
StanzaHandlerData(false, false, stanza, TypedMap()),
);
await Future<void>.delayed(const Duration(seconds: 2));
final cachedItem = await manager.getCachedDiscoInfoFromJid(aliceJid);
expect(
@@ -549,6 +551,7 @@ void main() {
stanza,
StanzaHandlerData(false, false, stanza, TypedMap()),
);
await Future<void>.delayed(const Duration(seconds: 2));
final cachedItem = await manager.getCachedDiscoInfoFromJid(aliceJid);
expect(

View File

@@ -433,10 +433,11 @@ void main() {
});
test('Test a failed stream resumption', () async {
final fakeSocket = StubTCPSocket([
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
'''
final fakeSocket = StubTCPSocket(
[
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
@@ -448,14 +449,14 @@ void main() {
<mechanism>PLAIN</mechanism>
</mechanisms>
</stream:features>''',
),
StringExpectation(
"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>AHBvbHlub21kaXZpc2lvbgBhYWFh</auth>",
'<success xmlns="urn:ietf:params:xml:ns:xmpp-sasl" />',
),
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
'''
),
StringExpectation(
"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='PLAIN'>AHBvbHlub21kaXZpc2lvbgBhYWFh</auth>",
'<success xmlns="urn:ietf:params:xml:ns:xmpp-sasl" />',
),
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='test.server' from='polynomdivision@test.server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
@@ -473,21 +474,22 @@ void main() {
<sm xmlns="urn:xmpp:sm:3"/>
</stream:features>
''',
),
StringExpectation(
"<resume xmlns='urn:xmpp:sm:3' previd='id-1' h='10' />",
"<failed xmlns='urn:xmpp:sm:3' h='another-sequence-number'><item-not-found xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/></failed>",
),
StanzaExpectation(
'<iq xmlns="jabber:client" type="set" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"/></iq>',
'<iq xmlns="jabber:client" type="result" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><jid>polynomdivision@test.server/MU29eEZn</jid></bind></iq>',
ignoreId: true,
),
StringExpectation(
"<enable xmlns='urn:xmpp:sm:3' resume='true' />",
'<enabled xmlns="urn:xmpp:sm:3" id="id-2" resume="true" />',
)
]);
),
StringExpectation(
"<resume xmlns='urn:xmpp:sm:3' previd='id-1' h='10' />",
"<failed xmlns='urn:xmpp:sm:3' h='another-sequence-number'><item-not-found xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/></failed>",
),
StanzaExpectation(
'<iq xmlns="jabber:client" type="set" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"/></iq>',
'<iq xmlns="jabber:client" type="result" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><jid>polynomdivision@test.server/MU29eEZn</jid></bind></iq>',
ignoreId: true,
),
StringExpectation(
"<enable xmlns='urn:xmpp:sm:3' resume='true' />",
'<enabled xmlns="urn:xmpp:sm:3" id="id-2" resume="true" />',
),
],
);
final conn = XmppConnection(
TestingReconnectionPolicy(),

View File

@@ -115,17 +115,19 @@ void main() {
test('Test sending a message processing hint', () async {
final manager = MessageManager();
final holder = TestingManagerHolder(
stubSocket: StubTCPSocket([
StanzaExpectation(
'''
stubSocket: StubTCPSocket(
[
StanzaExpectation(
'''
<message to="user@example.org" type="chat">
<no-copy xmlns="urn:xmpp:hints"/>
<no-store xmlns="urn:xmpp:hints"/>
</message>
''',
'',
)
]),
'',
),
],
),
);
await holder.register([

View File

@@ -6,7 +6,7 @@ void main() {
test('invariance', () {
final headers = {
'authorization': 'Basic Base64String==',
'cookie': 'foo=bar; user=romeo'
'cookie': 'foo=bar; user=romeo',
};
expect(
prepareHeaders(headers),
@@ -16,7 +16,7 @@ void main() {
test('invariance through uppercase', () {
final headers = {
'Authorization': 'Basic Base64String==',
'Cookie': 'foo=bar; user=romeo'
'Cookie': 'foo=bar; user=romeo',
};
expect(
prepareHeaders(headers),
@@ -27,7 +27,7 @@ void main() {
final headers = {
'Authorization': 'Basic Base64String==',
'Cookie': 'foo=bar; user=romeo',
'X-Tracking': 'Base64String=='
'X-Tracking': 'Base64String==',
};
expect(prepareHeaders(headers), {
'Authorization': 'Basic Base64String==',

View File

@@ -216,6 +216,89 @@ void main() {
expect(result.isType<XmppError>(), false);
});
test('Test SCRAM-SHA-1 SASL2 negotiation with a valid signature', () async {
final fakeSocket = StubTCPSocket([
StringExpectation(
"<stream:stream xmlns='jabber:client' version='1.0' xmlns:stream='http://etherx.jabber.org/streams' to='server' from='user@server' xml:lang='en'>",
'''
<stream:stream
xmlns="jabber:client"
version="1.0"
xmlns:stream="http://etherx.jabber.org/streams"
from="server"
xml:lang="en">
<stream:features xmlns="http://etherx.jabber.org/streams">
<mechanisms xmlns="urn:ietf:params:xml:ns:xmpp-sasl">
<mechanism>PLAIN</mechanism>
<mechanism>SCRAM-SHA-1</mechanism>
</mechanisms>
<authentication xmlns='urn:xmpp:sasl:2'>
<mechanism>PLAIN</mechanism>
<mechanism>SCRAM-SHA-1</mechanism>
</authentication>
<bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/>
</bind>
</stream:features>''',
),
StanzaExpectation(
"<authenticate xmlns='urn:xmpp:sasl:2' mechanism='SCRAM-SHA-1'><user-agent id='d4565fa7-4d72-4749-b3d3-740edbf87770'><software>moxxmpp</software><device>PapaTutuWawa's awesome device</device></user-agent><initial-response>biwsbj11c2VyLHI9ZnlrbytkMmxiYkZnT05Sdjlxa3hkYXdM</initial-response></authenticate>",
'''
<challenge xmlns='urn:xmpp:sasl:2'>cj1meWtvK2QybGJiRmdPTlJ2OXFreGRhd0wzcmZjTkhZSlkxWlZ2V1ZzN2oscz1RU1hDUitRNnNlazhiZjkyLGk9NDA5Ng==</challenge>
''',
),
StanzaExpectation(
'<response xmlns="urn:xmpp:sasl:2">Yz1iaXdzLHI9ZnlrbytkMmxiYkZnT05Sdjlxa3hkYXdMM3JmY05IWUpZMVpWdldWczdqLHA9djBYOHYzQnoyVDBDSkdiSlF5RjBYK0hJNFRzPQ==</response>',
'<success xmlns="urn:xmpp:sasl:2"><additional-data>dj1ybUY5cHFWOFM3c3VBb1pXamE0ZEpSa0ZzS1E9</additional-data><authorization-identifier>user@server</authorization-identifier></success>',
),
StanzaExpectation(
"<iq xmlns='jabber:client' type='set' id='aaaa'><bind xmlns='urn:ietf:params:xml:ns:xmpp-bind' /></iq>",
'''
'<iq xmlns="jabber:client" type="result" id="a"><bind xmlns="urn:ietf:params:xml:ns:xmpp-bind"><jid>polynomdivision@test.server/MU29eEZn</jid></bind></iq>',
''',
adjustId: true,
ignoreId: true,
),
]);
final conn = XmppConnection(
TestingReconnectionPolicy(),
AlwaysConnectedConnectivityManager(),
ClientToServerNegotiator(),
fakeSocket,
)..connectionSettings = ConnectionSettings(
jid: JID.fromString('user@server'),
password: 'pencil',
);
await conn.registerManagers([
PresenceManager(),
RosterManager(TestingRosterStateManager('', [])),
DiscoManager([]),
]);
await conn.registerFeatureNegotiators([
SaslPlainNegotiator(),
SaslScramNegotiator(
10,
'n=user,r=fyko+d2lbbFgONRv9qkxdawL',
'fyko+d2lbbFgONRv9qkxdawL',
ScramHashType.sha1,
),
ResourceBindingNegotiator(),
Sasl2Negotiator()
..userAgent = const UserAgent(
id: 'd4565fa7-4d72-4749-b3d3-740edbf87770',
software: 'moxxmpp',
device: "PapaTutuWawa's awesome device",
),
]);
final result = await conn.connect(
waitUntilLogin: true,
shouldReconnect: false,
enableReconnectOnSuccess: false,
);
expect(result.isType<NegotiatorError>(), false);
});
test('Test SCRAM-SHA-1 SASL2 negotiation with an invalid signature',
() async {
final fakeSocket = StubTCPSocket([

View File

@@ -253,7 +253,7 @@ void main() {
<message id="aaaaaaaaa" from="user@example.org" to="polynomdivision@test.server/abc123" type="chat">
<body>> Anna wrote:\n> We should bake a cake\nGreat idea!</body>
<reply to='anna@example.com/laptop' id='message-id1' xmlns='urn:xmpp:reply:0' />
<fallback xmlns='urn:xmpp:feature-fallback:0' for='urn:xmpp:reply:0'>
<fallback xmlns='urn:xmpp:fallback:0' for='urn:xmpp:reply:0'>
<body start="0" end="38" />
</fallback>
</message>

View File

@@ -11,14 +11,16 @@ void main() {
final controller = StreamController<String>();
unawaited(
controller.stream.transform(parser).forEach((event) {
if (event is! XMPPStreamElement) return;
final node = event.node;
controller.stream.transform(parser).forEach((events) {
for (final event in events) {
if (event is! XMPPStreamElement) continue;
final node = event.node;
if (node.tag == 'childa') {
childa = true;
} else if (node.tag == 'childb') {
childb = true;
if (node.tag == 'childa') {
childa = true;
} else if (node.tag == 'childb') {
childb = true;
}
}
}),
);
@@ -36,14 +38,16 @@ void main() {
final controller = StreamController<String>();
unawaited(
controller.stream.transform(parser).forEach((event) {
if (event is! XMPPStreamElement) return;
final node = event.node;
controller.stream.transform(parser).forEach((events) {
for (final event in events) {
if (event is! XMPPStreamElement) continue;
final node = event.node;
if (node.tag == 'childa') {
childa = true;
} else if (node.tag == 'childb') {
childb = true;
if (node.tag == 'childa') {
childa = true;
} else if (node.tag == 'childb') {
childb = true;
}
}
}),
);
@@ -64,14 +68,16 @@ void main() {
final controller = StreamController<String>();
unawaited(
controller.stream.transform(parser).forEach((event) {
if (event is! XMPPStreamElement) return;
final node = event.node;
controller.stream.transform(parser).forEach((events) {
for (final event in events) {
if (event is! XMPPStreamElement) continue;
final node = event.node;
if (node.tag == 'childa') {
childa = true;
} else if (node.tag == 'childb') {
childb = true;
if (node.tag == 'childa') {
childa = true;
} else if (node.tag == 'childb') {
childb = true;
}
}
}),
);
@@ -93,13 +99,15 @@ void main() {
final controller = StreamController<String>();
unawaited(
controller.stream.transform(parser).forEach((node) {
if (node is XMPPStreamElement) {
if (node.node.tag == 'childa') {
childa = true;
controller.stream.transform(parser).forEach((events) {
for (final event in events) {
if (event is XMPPStreamElement) {
if (event.node.tag == 'childa') {
childa = true;
}
} else if (event is XMPPStreamHeader) {
attrs = event.attributes;
}
} else if (node is XMPPStreamHeader) {
attrs = node.attributes;
}
}),
);
@@ -118,11 +126,13 @@ void main() {
var gotFeatures = false;
unawaited(
controller.stream.transform(parser).forEach(
(event) {
if (event is! XMPPStreamElement) return;
(events) {
for (final event in events) {
if (event is! XMPPStreamElement) continue;
if (event.node.tag == 'stream:features') {
gotFeatures = true;
if (event.node.tag == 'stream:features') {
gotFeatures = true;
}
}
},
),
@@ -157,4 +167,27 @@ void main() {
await Future<void>.delayed(const Duration(seconds: 1));
expect(gotFeatures, true);
});
test('Test the order of concatenated stanzas', () async {
// NOTE: This seems weird, but it turns out that not keeping this order leads to
// MUC joins (on Moxxy) not catching every bit of presence before marking the
// MUC as joined.
final parser = XMPPStreamParser();
final controller = StreamController<String>();
var called = false;
unawaited(
controller.stream.transform(parser).forEach((events) {
expect(events.isNotEmpty, true);
expect((events[0] as XMPPStreamElement).node.tag, 'childa');
expect((events[1] as XMPPStreamElement).node.tag, 'childb');
expect((events[2] as XMPPStreamElement).node.tag, 'childc');
called = true;
}),
);
controller.add('<childa /><childb /><childc />');
await Future<void>.delayed(const Duration(seconds: 2));
expect(called, true);
});
}

30
packages/moxxmpp_color/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.packages
build/

View File

@@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "efbf63d9c66b9f6ec30e9ad4611189aa80003d31"
channel: "stable"
project_type: package

View File

@@ -0,0 +1,3 @@
## 0.1.0
- Implement functions to compute a color from a given input, following XEP-0392.

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2023 Alexander "PapaTutuWawa"
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,7 @@
# moxxmpp_color
An implementation of [XEP-0392](https://xmpp.org/extensions/xep-0392.html).
## License
See `LICENSE`.

View File

@@ -0,0 +1,10 @@
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
file_names: false
unnecessary_library_directive: false

View File

@@ -0,0 +1,56 @@
library moxxmpp_color;
import 'dart:convert';
import 'package:cryptography/cryptography.dart';
import 'package:flutter/material.dart';
import 'package:hsluv/extensions.dart';
/// The default saturation to use.
const _defaultSaturation = 50;
/// The default lightness to use.
const _defaultLightness = 50;
/// Implementation of the algorithm in XEP-0392. [hashBytes] are the bytes
/// of the SHA-1 hash of the input.
Color _computeColor(
List<int> hashBytes, {
double? saturation,
double? lightness,
}) {
final bytes = hashBytes.sublist(0, 2);
final angle = (bytes.last << 8 + bytes.first).toDouble() / 65565;
return hsluvToRGBColor([
angle * 360,
(saturation ?? _defaultSaturation).remainder(360),
(lightness ?? _defaultLightness).remainder(360),
]);
}
/// Like [consistentColor], but synchronous.
Color consistentColorSync(
String input, {
double? saturation,
double? lightness,
}) {
return _computeColor(
Sha1().toSync().hashSync(utf8.encode(input)).bytes,
saturation: saturation,
lightness: lightness,
);
}
/// Compute the color based on the algorithm described in XEP-0392.
/// [saturation] and [lightness] can be used to supply values to use
/// instead of the default.
Future<Color> consistentColor(
String input, {
double? saturation,
double? lightness,
}) async {
return _computeColor(
(await Sha1().hash(utf8.encode(input))).bytes,
saturation: saturation,
lightness: lightness,
);
}

View File

@@ -0,0 +1,22 @@
name: moxxmpp_color
description: Implementation of XEP-0392
version: 0.1.0
homepage: https://codeberg.org/moxxy/moxxmpp
publish_to: https://git.polynom.me/api/packages/Moxxy/pub
environment:
sdk: '>=3.1.0 <4.0.0'
flutter: ">=1.17.0"
dependencies:
cryptography: ^2.7.0
flutter:
sdk: flutter
hsluv: ^1.1.3
dev_dependencies:
flutter_test:
sdk: flutter
very_good_analysis: ^5.1.0
flutter:

View File

@@ -1,3 +1,8 @@
## 0.4.0
- Keep version in sync with moxxmpp
- *BREAKING*: `TCPSocketWrapper` now takes a boolean parameter that enables logging of all incoming and outgoing data.
## 0.3.1
- Keep version in sync with moxxmpp

View File

@@ -5,7 +5,7 @@ import 'package:test/test.dart';
Future<void> _runTest(String domain) async {
var gotTLSException = false;
final socket = TCPSocketWrapper();
final socket = TCPSocketWrapper(true);
final log = Logger('TestLogger');
socket.getEventStream().listen((event) {
if (event is XmppSocketTLSFailedEvent) {

View File

@@ -19,7 +19,7 @@ void main() {
TestingSleepReconnectionPolicy(10),
AlwaysConnectedConnectivityManager(),
ClientToServerNegotiator(),
TCPSocketWrapper(),
TCPSocketWrapper(true),
)..connectionSettings = ConnectionSettings(
jid: JID.fromString('testuser@no-sasl.badxmpp.eu'),
password: 'abc123',
@@ -59,7 +59,7 @@ void main() {
TestingReconnectionPolicy(),
AlwaysConnectedConnectivityManager(),
ClientToServerNegotiator(),
TCPSocketWrapper(),
TCPSocketWrapper(true),
)..connectionSettings = ConnectionSettings(
jid: JID.fromString('testuser@no-sasl.badxmpp.eu'),
password: 'abc123',

View File

@@ -10,6 +10,11 @@ import 'package:moxxmpp_socket_tcp/src/rfc_2782.dart';
/// TCP socket implementation for XmppConnection
class TCPSocketWrapper extends BaseSocketWrapper {
TCPSocketWrapper(this._logIncomingOutgoing);
/// Flag controlling whether incoming/outgoing data is logged or not.
final bool _logIncomingOutgoing;
/// The underlying Socket/SecureSocket instance.
Socket? _socket;
@@ -212,7 +217,9 @@ class TCPSocketWrapper extends BaseSocketWrapper {
_socketSubscription = _socket!.listen(
(List<int> event) {
final data = utf8.decode(event);
_log.finest('<== $data');
if (_logIncomingOutgoing) {
_log.finest('<== $data');
}
_dataStream.add(data);
},
onError: (Object error) {
@@ -297,7 +304,9 @@ class TCPSocketWrapper extends BaseSocketWrapper {
return;
}
_log.finest('==> $data');
if (_logIncomingOutgoing) {
_log.finest('==> $data');
}
try {
_socket!.write(data);

View File

@@ -1,6 +1,6 @@
name: moxxmpp_socket_tcp
description: A socket for moxxmpp using TCP that implements the RFC6120 connection algorithm and XEP-0368
version: 0.3.1
version: 0.4.0
homepage: https://codeberg.org/moxxy/moxxmpp
publish_to: https://git.polynom.me/api/packages/Moxxy/pub
@@ -14,12 +14,6 @@ dependencies:
hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: ^0.4.0
dependency_overrides:
moxxmpp:
git:
url: https://codeberg.org/moxxy/moxxmpp.git
rev: 05e3d804a4036e9cd93fd27473a1e970fda3c3fc
dev_dependencies:
lints: ^2.0.0
test: ^1.16.0

View File

@@ -1,4 +0,0 @@
# melos_managed_dependency_overrides: moxxmpp
dependency_overrides:
moxxmpp:
path: ../moxxmpp