41 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
54 changed files with 1990 additions and 358 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. 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) ### [moxxmpp_socket_tcp](./packages/moxxmpp_socket_tcp)

View File

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

View File

@@ -75,11 +75,16 @@ void main(List<String> args) async {
}); });
// Join room // Join room
await connection.getManagerById<MUCManager>(mucManager)!.joinRoom( final mm = connection.getManagerById<MUCManager>(mucManager)!;
muc, await mm.joinRoom(
nick, muc,
maxHistoryStanzas: 0, 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: '> '); final repl = Repl(prompt: '> ');
await for (final line in repl.runAsync()) { await for (final line in repl.runAsync()) {

View File

@@ -30,7 +30,7 @@ void main(List<String> args) async {
TestingReconnectionPolicy(), TestingReconnectionPolicy(),
AlwaysConnectedConnectivityManager(), AlwaysConnectedConnectivityManager(),
ClientToServerNegotiator(), ClientToServerNegotiator(),
ExampleTCPSocketWrapper(parser.srvRecord), ExampleTCPSocketWrapper(parser.srvRecord, true),
)..connectionSettings = parser.connectionSettings; )..connectionSettings = parser.connectionSettings;
// Generate OMEMO data // 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 /// A simple socket for examples that allows injection of SRV records (since
/// we cannot use moxdns here). /// we cannot use moxdns here).
class ExampleTCPSocketWrapper extends TCPSocketWrapper { class ExampleTCPSocketWrapper extends TCPSocketWrapper {
ExampleTCPSocketWrapper(this.srvRecord); ExampleTCPSocketWrapper(this.srvRecord, bool logData) : super(logData);
/// A potential SRV record to inject for testing. /// A potential SRV record to inject for testing.
final MoxSrvRecord? srvRecord; final MoxSrvRecord? srvRecord;

View File

@@ -12,10 +12,10 @@ dependencies:
logging: ^1.0.2 logging: ^1.0.2
moxxmpp: moxxmpp:
hosted: https://git.polynom.me/api/packages/Moxxy/pub hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: 0.3.1 version: 0.4.0
moxxmpp_socket_tcp: moxxmpp_socket_tcp:
hosted: https://git.polynom.me/api/packages/Moxxy/pub hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: 0.3.1 version: 0.4.0
omemo_dart: omemo_dart:
hosted: https://git.polynom.me/api/packages/PapaTutuWawa/pub hosted: https://git.polynom.me/api/packages/PapaTutuWawa/pub
version: ^0.5.1 version: ^0.5.1

78
flake.lock generated
View File

@@ -7,11 +7,11 @@
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
}, },
"locked": { "locked": {
"lastModified": 1694377165, "lastModified": 1727554699,
"narHash": "sha256-NeIlZIElbkbKaNK5SZv6ULcFT/UGIICb3q7GPpkf9jk=", "narHash": "sha256-puBCNL5PW7Pej+6Srmi2YjEgNeE015NFe33hbkkLqeQ=",
"owner": "tadfisher", "owner": "tadfisher",
"repo": "android-nixpkgs", "repo": "android-nixpkgs",
"rev": "b020dc733ee69393841a50cf94d45735d5a5a57a", "rev": "bc34ef1c71fe9eafcfb1d637b431fca83d746625",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -25,15 +25,14 @@
"nixpkgs": [ "nixpkgs": [
"android-nixpkgs", "android-nixpkgs",
"nixpkgs" "nixpkgs"
], ]
"systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1693833206, "lastModified": 1722113426,
"narHash": "sha256-wHOY0nnD6gWj8u9uI85/YlsganYyWRK1hLFZulZwfmY=", "narHash": "sha256-Yo/3loq572A8Su6aY5GP56knpuKYRvM2a1meP9oJZCw=",
"owner": "numtide", "owner": "numtide",
"repo": "devshell", "repo": "devshell",
"rev": "65114ea495a8d3cc1352368bf170d67ef005aa5a", "rev": "67cce7359e4cd3c45296fb4aaf6a19e2a9c757ae",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -43,6 +42,24 @@
} }
}, },
"flake-utils": { "flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1726560853,
"narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": { "inputs": {
"systems": "systems_2" "systems": "systems_2"
}, },
@@ -60,31 +77,13 @@
"type": "github" "type": "github"
} }
}, },
"flake-utils_2": {
"inputs": {
"systems": "systems_3"
},
"locked": {
"lastModified": 1692799911,
"narHash": "sha256-3eihraek4qL744EvQXsK1Ha6C3CR7nnT8X2qWap4RNk=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "f9e7cf818399d17d347f847525c5a5a8032e4e44",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1694183432, "lastModified": 1727348695,
"narHash": "sha256-YyPGNapgZNNj51ylQMw9lAgvxtM2ai1HZVUu3GS8Fng=", "narHash": "sha256-J+PeFKSDV+pHL7ukkfpVzCOO7mBSrrpJ3svwBFABbhI=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "db9208ab987cdeeedf78ad9b4cf3c55f5ebd269b", "rev": "1925c603f17fc89f4c8f6bf6f631a802ad85d784",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -96,11 +95,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1694343207, "lastModified": 1727586919,
"narHash": "sha256-jWi7OwFxU5Owi4k2JmiL1sa/OuBCQtpaAesuj5LXC8w=", "narHash": "sha256-e/YXG0tO5GWHDS8QQauj8aj4HhXEm602q9swrrlTlKQ=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "78058d810644f5ed276804ce7ea9e82d92bee293", "rev": "2dcd9c55e8914017226f5948ac22c53872a13ee2",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -146,21 +145,6 @@
"repo": "default", "repo": "default",
"type": "github" "type": "github"
} }
},
"systems_3": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

View File

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

View File

@@ -1,2 +1,4 @@
set -ex
prosodyctl --config ./prosody.cfg.lua register testuser1 localhost abc123 prosodyctl --config ./prosody.cfg.lua register testuser1 localhost abc123
prosodyctl --config ./prosody.cfg.lua register testuser2 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 version: 1.0.0
environment: environment:
sdk: '>=2.18.0 <3.0.0' sdk: ">=3.0.0 <4.0.0"
dependencies: dependencies:
logging: ^1.0.2 logging: ^1.3.0
moxxmpp: 0.3.0 moxxmpp:
moxxmpp_socket_tcp: 0.3.0 path: ../packages/moxxmpp
moxxmpp_socket_tcp:
path: ../packages/moxxmpp_socket_tcp
dev_dependencies: dev_dependencies:
lints: ^2.0.0 build_runner: ^2.4.13
test: ^1.16.0 test: ^1.25.8
very_good_analysis: ^3.0.1 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'; import 'package:test/test.dart';
class TestingTCPSocketWrapper extends TCPSocketWrapper { class TestingTCPSocketWrapper extends TCPSocketWrapper {
TestingTCPSocketWrapper() : super(true);
@override @override
bool onBadCertificate(dynamic certificate, String domain) { bool onBadCertificate(dynamic certificate, String domain) {
return true; return true;

View File

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

View File

@@ -1,3 +1,6 @@
## 0.4.1
- Moved FAST from staging to xep_0484.dart
## 0.4.0 ## 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. - **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*: Rename `UserAvatarManager`'s `getUserAvatar` to `getUserAvatarData`. It now also requires the id of the avatar to fetch
- *BREAKING*: `UserAvatarManager`'s `getAvatarId` with `getLatestMetadata`. - *BREAKING*: `UserAvatarManager`'s `getAvatarId` with `getLatestMetadata`.
- The `PubSubManager` now supports PubSub's `max_items` in `getItems`. - 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 ## 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/stanza.dart';
export 'package:moxxmpp/src/stringxml.dart'; export 'package:moxxmpp/src/stringxml.dart';
export 'package:moxxmpp/src/util/typed_map.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/staging/file_upload_notification.dart';
export 'package:moxxmpp/src/xeps/xep_0004.dart'; export 'package:moxxmpp/src/xeps/xep_0004.dart';
export 'package:moxxmpp/src/xeps/xep_0030/errors.dart'; export 'package:moxxmpp/src/xeps/xep_0030/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/types.dart';
export 'package:moxxmpp/src/xeps/xep_0030/xep_0030.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/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/types.dart';
export 'package:moxxmpp/src/xeps/xep_0045/xep_0045.dart'; export 'package:moxxmpp/src/xeps/xep_0045/xep_0045.dart';
export 'package:moxxmpp/src/xeps/xep_0054.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_0448.dart';
export 'package:moxxmpp/src/xeps/xep_0449.dart'; export 'package:moxxmpp/src/xeps/xep_0449.dart';
export 'package:moxxmpp/src/xeps/xep_0461.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 'dart:async';
import 'package:meta/meta.dart';
import 'package:moxxmpp/src/jid.dart';
import 'package:moxxmpp/src/stringxml.dart'; import 'package:moxxmpp/src/stringxml.dart';
import 'package:synchronized/synchronized.dart'; import 'package:synchronized/synchronized.dart';
/// A surrogate key for awaiting stanzas. /// (JID we sent a stanza to, the id of the sent stanza, the tag of the sent stanza).
@immutable // ignore: avoid_private_typedef_functions
class _StanzaSurrogateKey { typedef _StanzaCompositeKey = (String?, String, String);
const _StanzaSurrogateKey(this.sentTo, this.id, this.tag);
/// The JID the original stanza was sent to. We expect the result to come from the /// Callback function that returns the bare JID of the connection as a String.
/// same JID. typedef GetBareJidCallback = String Function();
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;
}
}
/// This class handles the await semantics for stanzas. Stanzas are given a "unique" /// 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. /// 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. /// This class also handles some "edge cases" of RFC 6120, like an empty "from" attribute.
class StanzaAwaiter { class StanzaAwaiter {
StanzaAwaiter(this._bareJidCallback);
final GetBareJidCallback _bareJidCallback;
/// The pending stanzas, identified by their surrogate key. /// 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]. /// The critical section for accessing [StanzaAwaiter._pending].
final Lock _lock = Lock(); final Lock _lock = Lock();
@@ -52,30 +34,33 @@ class StanzaAwaiter {
/// [tag] is the stanza's tag name. /// [tag] is the stanza's tag name.
/// ///
/// Returns a future that might resolve to the response to the stanza. /// 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 = await _lock.synchronized(() {
final completer = Completer<XMLNode>(); final completer = Completer<XMLNode>();
_pending[_StanzaSurrogateKey(to, id, tag)] = completer; _pending[(processedTo, id, tag)] = completer;
return completer; return completer;
}); });
return completer.future; return completer.future;
} }
/// Checks if the stanza [stanza] is being awaited. [bareJid] is the bare JID of /// Checks if the stanza [stanza] is being awaited.
/// the connection.
/// If [stanza] is awaited, resolves the future and returns true. If not, returns /// If [stanza] is awaited, resolves the future and returns true. If not, returns
/// false. /// false.
Future<bool> onData(XMLNode stanza, JID bareJid) async { Future<bool> onData(XMLNode stanza) async {
assert(bareJid.isBare(), 'bareJid must be bare');
final id = stanza.attributes['id'] as String?; final id = stanza.attributes['id'] as String?;
if (id == null) return false; if (id == null) return false;
final key = _StanzaSurrogateKey( // Check if we want to send a stanza to our bare JID and replace it with null.
// Section 8.1.2.1 § 3 of RFC 6120 says that an empty "from" indicates that the final from = stanza.attributes['from'] as String?;
// attribute is implicitly from our own bare JID. final processedFrom =
stanza.attributes['from'] as String? ?? bareJid.toString(), from != null && from == _bareJidCallback() ? null : from;
final key = (
processedFrom,
id, id,
stanza.tag, stanza.tag,
); );
@@ -91,4 +76,19 @@ class StanzaAwaiter {
return false; 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/socket.dart';
import 'package:moxxmpp/src/stanza.dart'; import 'package:moxxmpp/src/stanza.dart';
import 'package:moxxmpp/src/stringxml.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/queue.dart';
import 'package:moxxmpp/src/util/typed_map.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_0030/xep_0030.dart';
import 'package:moxxmpp/src/xeps/xep_0198/xep_0198.dart'; import 'package:moxxmpp/src/xeps/xep_0198/xep_0198.dart';
import 'package:moxxmpp/src/xeps/xep_0352.dart'; import 'package:moxxmpp/src/xeps/xep_0352.dart';
import 'package:synchronized/synchronized.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
/// The states the XmppConnection can be in /// The states the XmppConnection can be in
@@ -49,6 +49,19 @@ enum XmppConnectionState {
error 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. /// This class is a connection to the server.
class XmppConnection { class XmppConnection {
XmppConnection( XmppConnection(
@@ -58,7 +71,11 @@ class XmppConnection {
this._socket, { this._socket, {
this.connectingTimeout = const Duration(minutes: 2), this.connectingTimeout = const Duration(minutes: 2),
}) : _reconnectionPolicy = reconnectionPolicy, }) : _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 // Allow the reconnection policy to perform reconnections by itself
_reconnectionPolicy.register( _reconnectionPolicy.register(
_attemptReconnection, _attemptReconnection,
@@ -77,9 +94,15 @@ class XmppConnection {
}, },
); );
_stanzaAwaiter = StanzaAwaiter(
() => connectionSettings.jid.toBare().toString(),
);
_incomingStanzaQueue = IncomingStanzaQueue(handleXmlStream, _stanzaAwaiter);
_socketStream = _socket.getDataStream(); _socketStream = _socket.getDataStream();
// TODO(Unknown): Handle on done _socketStream
_socketStream.transform(_streamParser).forEach(handleXmlStream); .transform(_streamParser)
.forEach(_incomingStanzaQueue.addStanza);
_socketStream.listen(_handleOnDataCallbacks);
_socket.getEventStream().listen(handleSocketEvent); _socket.getEventStream().listen(handleSocketEvent);
_stanzaQueue = AsyncStanzaQueue( _stanzaQueue = AsyncStanzaQueue(
@@ -109,16 +132,16 @@ class XmppConnection {
final ConnectivityManager _connectivityManager; final ConnectivityManager _connectivityManager;
/// A helper for handling await semantics with stanzas /// 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 /// Sorted list of handlers that we call or incoming and outgoing stanzas
final List<StanzaHandler> _incomingStanzaHandlers = final List<_StanzaHandlerWrapper> _incomingStanzaHandlers =
List.empty(growable: true); List.empty(growable: true);
final List<StanzaHandler> _incomingPreStanzaHandlers = final List<_StanzaHandlerWrapper> _incomingPreStanzaHandlers =
List.empty(growable: true); List.empty(growable: true);
final List<StanzaHandler> _outgoingPreStanzaHandlers = final List<_StanzaHandlerWrapper> _outgoingPreStanzaHandlers =
List.empty(growable: true); List.empty(growable: true);
final List<StanzaHandler> _outgoingPostStanzaHandlers = final List<_StanzaHandlerWrapper> _outgoingPostStanzaHandlers =
List.empty(growable: true); List.empty(growable: true);
final StreamController<XmppEvent> _eventStreamController = final StreamController<XmppEvent> _eventStreamController =
StreamController.broadcast(); StreamController.broadcast();
@@ -157,10 +180,6 @@ class XmppConnection {
T? getNegotiatorById<T extends XmppFeatureNegotiatorBase>(String id) => T? getNegotiatorById<T extends XmppFeatureNegotiatorBase>(String id) =>
_negotiationsHandler.getNegotiatorById<T>(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 /// The logger for the class
final Logger _log = Logger('XmppConnection'); final Logger _log = Logger('XmppConnection');
@@ -169,6 +188,8 @@ class XmppConnection {
bool get isAuthenticated => _isAuthenticated; bool get isAuthenticated => _isAuthenticated;
late final IncomingStanzaQueue _incomingStanzaQueue;
late final AsyncStanzaQueue _stanzaQueue; late final AsyncStanzaQueue _stanzaQueue;
/// Returns the JID we authenticate with and add the resource that we have bound. /// Returns the JID we authenticate with and add the resource that we have bound.
@@ -198,18 +219,25 @@ class XmppConnection {
_xmppManagers[manager.id] = manager; _xmppManagers[manager.id] = manager;
_incomingStanzaHandlers.addAll(manager.getIncomingStanzaHandlers()); _incomingStanzaHandlers.addAll(
_incomingPreStanzaHandlers.addAll(manager.getIncomingPreStanzaHandlers()); manager.getIncomingStanzaHandlers().map((h) => (h, manager.name)),
_outgoingPreStanzaHandlers.addAll(manager.getOutgoingPreStanzaHandlers()); );
_outgoingPostStanzaHandlers _incomingPreStanzaHandlers.addAll(
.addAll(manager.getOutgoingPostStanzaHandlers()); 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 // Sort them
_incomingStanzaHandlers.sort(stanzaHandlerSortComparator); _incomingStanzaHandlers.sort(_stanzaHandlerWrapperSortComparator);
_incomingPreStanzaHandlers.sort(stanzaHandlerSortComparator); _incomingPreStanzaHandlers.sort(_stanzaHandlerWrapperSortComparator);
_outgoingPreStanzaHandlers.sort(stanzaHandlerSortComparator); _outgoingPreStanzaHandlers.sort(_stanzaHandlerWrapperSortComparator);
_outgoingPostStanzaHandlers.sort(stanzaHandlerSortComparator); _outgoingPostStanzaHandlers.sort(_stanzaHandlerWrapperSortComparator);
// Run the post register callbacks // Run the post register callbacks
for (final manager in _xmppManagers.values) { for (final manager in _xmppManagers.values) {
@@ -290,6 +318,13 @@ class XmppConnection {
return getManagerById(csiManager); 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. /// Attempts to reconnect to the server by following an exponential backoff.
Future<void> _attemptReconnection() async { Future<void> _attemptReconnection() async {
_log.finest('_attemptReconnection: Setting state to notConnected'); _log.finest('_attemptReconnection: Setting state to notConnected');
@@ -515,7 +550,7 @@ class XmppConnection {
// A stanza with no to attribute is for direct processing by the server. As such, // 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 // we can correlate it by just *assuming* we have that attribute
// (RFC 6120 Section 8.1.1.1) // (RFC 6120 Section 8.1.1.1)
data.stanza.to ?? connectionSettings.jid.toBare().toString(), data.stanza.to,
data.stanza.id!, data.stanza.id!,
data.stanza.tag, data.stanza.tag,
) )
@@ -650,15 +685,30 @@ class XmppConnection {
/// call its callback and end the processing if the callback returned true; continue /// call its callback and end the processing if the callback returned true; continue
/// if it returned false. /// if it returned false.
Future<StanzaHandlerData> _runStanzaHandlers( Future<StanzaHandlerData> _runStanzaHandlers(
List<StanzaHandler> handlers, List<_StanzaHandlerWrapper> handlers,
Stanza stanza, { Stanza stanza, {
StanzaHandlerData? initial, StanzaHandlerData? initial,
}) async { }) async {
var state = initial ?? StanzaHandlerData(false, false, stanza, TypedMap()); 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)) { if (handler.matches(state.stanza)) {
state = await handler.callback(state.stanza, state); _log.finest(
if (state.done || state.cancel) return state; '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( final awaited = await _stanzaAwaiter.onData(
incomingPreHandlers.stanza, incomingPreHandlers.stanza,
connectionSettings.jid.toBare(),
); );
if (awaited) { if (awaited) {
return; return;
@@ -802,14 +851,12 @@ class XmppConnection {
// causing (a) the negotiator to become confused and (b) the stanzas/nonzas to be // 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 // missed. This causes the data to wait while the negotiator is running and thus
// prevent this issue. // prevent this issue.
await _negotiationLock.synchronized(() async { if (_routingState != RoutingState.negotiating) {
if (_routingState != RoutingState.negotiating) { unawaited(handleXmlStream(event));
unawaited(handleXmlStream(event)); return;
return; }
}
await _negotiationsHandler.negotiate(event); await _negotiationsHandler.negotiate(event);
});
break; break;
case RoutingState.handleStanzas: case RoutingState.handleStanzas:
await _handleStanza(node); await _handleStanza(node);

View File

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

View File

@@ -80,6 +80,9 @@ abstract class XmppManagerBase {
/// handler's priority, the earlier it is run. /// handler's priority, the earlier it is run.
List<NonzaHandler> getNonzaHandlers() => []; 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. /// Return a list of features that should be included in a disco response.
List<String> getDiscoFeatures() => []; List<String> getDiscoFeatures() => [];

View File

@@ -13,6 +13,7 @@ const subscriptionPreApprovalXmlns = 'urn:xmpp:features:pre-approval';
// XEP-0004 // XEP-0004
const dataFormsXmlns = 'jabber:x:data'; const dataFormsXmlns = 'jabber:x:data';
const formVarFormType = 'FORM_TYPE';
// XEP-0030 // XEP-0030
const discoInfoXmlns = 'http://jabber.org/protocol/disco#info'; const discoInfoXmlns = 'http://jabber.org/protocol/disco#info';
@@ -23,6 +24,8 @@ const extendedAddressingXmlns = 'http://jabber.org/protocol/address';
// XEP-0045 // XEP-0045
const mucXmlns = 'http://jabber.org/protocol/muc'; 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 // XEP-0054
const vCardTempXmlns = 'vcard-temp'; const vCardTempXmlns = 'vcard-temp';
@@ -163,7 +166,6 @@ const stickersXmlns = 'urn:xmpp:stickers:0';
// XEP-0461 // XEP-0461
const replyXmlns = 'urn:xmpp:reply:0'; const replyXmlns = 'urn:xmpp:reply:0';
const fallbackXmlns = 'urn:xmpp:feature-fallback:0';
// ??? // ???
const urlDataXmlns = 'http://jabber.org/protocol/url-data'; 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. /// A buffer to put between a socket's input and a full XML stream.
class XMPPStreamParser extends StreamTransformerBase<String, XMPPStreamObject> { class XMPPStreamParser
final StreamController<XMPPStreamObject> _streamController = extends StreamTransformerBase<String, List<XMPPStreamObject>> {
StreamController<XMPPStreamObject>(); final StreamController<List<XMPPStreamObject>> _streamController =
StreamController<List<XMPPStreamObject>>();
/// Turns a String into a list of [XmlEvent]s in a chunked fashion. /// Turns a String into a list of [XmlEvent]s in a chunked fashion.
_ChunkedConversionBuffer<String, XmlEvent> _eventBuffer = _ChunkedConversionBuffer<String, XmlEvent> _eventBuffer =
@@ -117,13 +118,14 @@ class XMPPStreamParser extends StreamTransformerBase<String, XMPPStreamObject> {
} }
@override @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 // 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 // create streams we cannot close. We need to be able to destroy and recreate an
// XML parser whenever we start a new connection. // XML parser whenever we start a new connection.
stream.listen((input) { stream.listen((input) {
final events = _eventBuffer.convert(input); final events = _eventBuffer.convert(input);
final streamHeaderEvents = _streamHeaderSelector.convert(events); final streamHeaderEvents = _streamHeaderSelector.convert(events);
final objects = List<XMPPStreamObject>.empty(growable: true);
// Process the stream header separately. // Process the stream header separately.
for (final event in streamHeaderEvents) { for (final event in streamHeaderEvents) {
@@ -135,7 +137,7 @@ class XMPPStreamParser extends StreamTransformerBase<String, XMPPStreamObject> {
continue; continue;
} }
_streamController.add( objects.add(
XMPPStreamHeader( XMPPStreamHeader(
Map<String, String>.fromEntries( Map<String, String>.fromEntries(
event.attributes.map((attr) { event.attributes.map((attr) {
@@ -151,13 +153,15 @@ class XMPPStreamParser extends StreamTransformerBase<String, XMPPStreamObject> {
final children = _childBuffer.convert(childEvents); final children = _childBuffer.convert(childEvents);
for (final node in children) { for (final node in children) {
if (node.nodeType == XmlNodeType.ELEMENT) { if (node.nodeType == XmlNodeType.ELEMENT) {
_streamController.add( objects.add(
XMPPStreamElement( XMPPStreamElement(
XMLNode.fromXmlElement(node as XmlElement), XMLNode.fromXmlElement(node as XmlElement),
), ),
); );
} }
} }
_streamController.add(objects);
}); });
return _streamController.stream; return _streamController.stream;

View File

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

View File

@@ -246,6 +246,9 @@ class SaslScramNegotiator extends Sasl2AuthenticationNegotiator {
bool _checkSignature(String base64Signature) { bool _checkSignature(String base64Signature) {
final signature = final signature =
parseKeyValue(utf8.decode(base64.decode(base64Signature))); parseKeyValue(utf8.decode(base64.decode(base64Signature)));
_log.finest(
'Expecting signature: "$_serverSignature", got: "${signature["v"]}"',
);
return signature['v']! == _serverSignature; return signature['v']! == _serverSignature;
} }
@@ -360,6 +363,11 @@ class SaslScramNegotiator extends Sasl2AuthenticationNegotiator {
@override @override
Future<Result<bool, NegotiatorError>> onSasl2Success(XMLNode response) async { 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 // When we're done with SASL2, check the additional data to verify the server
// signature. // signature.
state = NegotiatorState.done; state = NegotiatorState.done;

View File

@@ -182,8 +182,18 @@ class RosterManager extends XmppManagerBase {
/// Shared code between requesting rosters without and with roster versioning, if /// Shared code between requesting rosters without and with roster versioning, if
/// the server deems a regular roster response more efficient than n roster pushes. /// 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( Future<Result<RosterRequestResult, RosterError>> _handleRosterResponse(
XMLNode? query, XMLNode? query,
String? requestedRosterVersion,
) async { ) async {
final List<XmppRosterItem> items; final List<XmppRosterItem> items;
String? rosterVersion; String? rosterVersion;
@@ -204,6 +214,14 @@ class RosterManager extends XmppManagerBase {
.toList(); .toList();
rosterVersion = query.attributes['ver'] as String?; rosterVersion = query.attributes['ver'] as String?;
} else if (requestedRosterVersion != null) {
// Skip the handleRosterFetch call since nothing changed.
return Result(
RosterRequestResult(
[],
requestedRosterVersion,
),
);
} else { } else {
logger.warning( 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', '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); 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 /// 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>> Future<Result<RosterRequestResult?, RosterError>>
requestRosterPushes() async { requestRosterPushes() async {
final attrs = getAttributes(); final attrs = getAttributes();
final rosterVersion = await _stateManager.getRosterVersion();
final result = (await attrs.sendStanza( final result = (await attrs.sendStanza(
StanzaDetails( StanzaDetails(
Stanza.iq( Stanza.iq(
@@ -275,7 +294,7 @@ class RosterManager extends XmppManagerBase {
tag: 'query', tag: 'query',
xmlns: rosterXmlns, xmlns: rosterXmlns,
attributes: { attributes: {
'ver': await _stateManager.getRosterVersion() ?? '', 'ver': rosterVersion ?? '',
}, },
), ),
], ],
@@ -289,7 +308,7 @@ class RosterManager extends XmppManagerBase {
} }
final query = result.firstTag('query', xmlns: rosterXmlns); final query = result.firstTag('query', xmlns: rosterXmlns);
return _handleRosterResponse(query); return _handleRosterResponse(query, rosterVersion);
} }
bool rosterVersioningAvailable() { bool rosterVersioningAvailable() {

View File

@@ -102,6 +102,8 @@ class RemoteServerTimeoutError extends StanzaError {
/// An unknown error. /// An unknown error.
class UnknownStanzaError extends StanzaError {} class UnknownStanzaError extends StanzaError {}
const _stanzaNotDefined = Object();
class Stanza extends XMLNode { class Stanza extends XMLNode {
// ignore: use_super_parameters // ignore: use_super_parameters
Stanza({ Stanza({
@@ -216,7 +218,7 @@ class Stanza extends XMLNode {
Stanza copyWith({ Stanza copyWith({
String? id, String? id,
String? from, Object? from = _stanzaNotDefined,
String? to, String? to,
String? type, String? type,
List<XMLNode>? children, List<XMLNode>? children,
@@ -225,7 +227,7 @@ class Stanza extends XMLNode {
return Stanza( return Stanza(
tag: tag, tag: tag,
to: to ?? this.to, to: to ?? this.to,
from: from ?? this.from, from: from != _stanzaNotDefined ? from as String? : this.from,
id: id ?? this.id, id: id ?? this.id,
type: type ?? this.type, type: type ?? this.type,
children: children ?? this.children, children: children ?? this.children,

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

@@ -17,3 +17,9 @@ class NoNicknameSpecified extends MUCError {}
/// them to be a member of a room, but they are not currently joined to /// them to be a member of a room, but they are not currently joined to
/// that room. /// that room.
class RoomNotJoinedError extends MUCError {} 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/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'; 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 { class RoomInformation {
/// Represents information about a Multi-User Chat (MUC) room. /// Represents information about a Multi-User Chat (MUC) room.
RoomInformation({ RoomInformation({
required this.jid, required this.jid,
required this.features, required this.features,
required this.name, required this.name,
this.roomInfo,
}); });
/// Constructs a [RoomInformation] object from a [DiscoInfo] object. /// Constructs a [RoomInformation] object from a [DiscoInfo] object.
@@ -21,6 +86,11 @@ class RoomInformation {
name: discoInfo.identities name: discoInfo.identities
.firstWhere((i) => i.category == 'conference') .firstWhere((i) => i.category == 'conference')
.name!, .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. /// The JID of the Multi-User Chat (MUC) room.
@@ -31,11 +101,40 @@ class RoomInformation {
/// The name or title of the Multi-User Chat (MUC) room. /// The name or title of the Multi-User Chat (MUC) room.
final String name; final String name;
/// The data form containing room information.
final DataForm? roomInfo;
} }
/// The used message-id and an optional origin-id. /// The used message-id and an optional origin-id.
typedef PendingMessage = (String, String?); 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 { class RoomState {
RoomState({required this.roomJid, this.nick, required this.joined}) { RoomState({required this.roomJid, this.nick, required this.joined}) {
pendingMessages = List<PendingMessage>.empty(growable: true); pendingMessages = List<PendingMessage>.empty(growable: true);
@@ -50,5 +149,15 @@ class RoomState {
/// Flag whether we're joined and can process messages /// Flag whether we're joined and can process messages
bool joined; 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; late final List<PendingMessage> pendingMessages;
/// "List" of entities inside the MUC.
final Map<String, RoomMember> members = {};
} }

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:moxlib/moxlib.dart'; import 'package:moxlib/moxlib.dart';
import 'package:moxxmpp/src/events.dart'; import 'package:moxxmpp/src/events.dart';
import 'package:moxxmpp/src/jid.dart'; import 'package:moxxmpp/src/jid.dart';
@@ -6,14 +7,16 @@ import 'package:moxxmpp/src/managers/data.dart';
import 'package:moxxmpp/src/managers/handlers.dart'; import 'package:moxxmpp/src/managers/handlers.dart';
import 'package:moxxmpp/src/managers/namespaces.dart'; import 'package:moxxmpp/src/managers/namespaces.dart';
import 'package:moxxmpp/src/namespaces.dart'; import 'package:moxxmpp/src/namespaces.dart';
import 'package:moxxmpp/src/presence.dart';
import 'package:moxxmpp/src/stanza.dart'; import 'package:moxxmpp/src/stanza.dart';
import 'package:moxxmpp/src/stringxml.dart'; import 'package:moxxmpp/src/stringxml.dart';
import 'package:moxxmpp/src/xeps/xep_0030/types.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_0030/xep_0030.dart';
import 'package:moxxmpp/src/xeps/xep_0045/errors.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_0045/types.dart';
import 'package:moxxmpp/src/xeps/xep_0359.dart'; import 'package:moxxmpp/src/xeps/xep_0359.dart';
import 'package:synchronized/extension.dart';
import 'package:synchronized/synchronized.dart'; import 'package:synchronized/synchronized.dart';
/// (Room JID, nickname) /// (Room JID, nickname)
@@ -25,9 +28,12 @@ class MUCManager extends XmppManagerBase {
@override @override
Future<bool> isSupported() async => true; Future<bool> isSupported() async => true;
/// Map full JID to RoomState /// Map a room's JID to its RoomState
final Map<JID, RoomState> _mucRoomCache = {}; 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 /// Cache lock
final Lock _cacheLock = Lock(); final Lock _cacheLock = Lock();
@@ -43,6 +49,14 @@ class MUCManager extends XmppManagerBase {
// Before the message handler // Before the message handler
priority: -99, priority: -99,
), ),
StanzaHandler(
stanzaTag: 'presence',
callback: _onPresence,
tagName: 'x',
tagXmlns: mucUserXmlns,
// Before the PresenceManager
priority: PresenceManager.presenceHandlerPriority + 1,
),
]; ];
@override @override
@@ -70,6 +84,7 @@ class MUCManager extends XmppManagerBase {
// Mark all groupchats as not joined. // Mark all groupchats as not joined.
for (final jid in _mucRoomCache.keys) { for (final jid in _mucRoomCache.keys) {
_mucRoomCache[jid]!.joined = false; _mucRoomCache[jid]!.joined = false;
_mucRoomJoinCompleter[jid] = Completer();
// Re-join all MUCs. // Re-join all MUCs.
final state = _mucRoomCache[jid]!; final state = _mucRoomCache[jid]!;
@@ -129,7 +144,8 @@ class MUCManager extends XmppManagerBase {
); );
return Result(roomInformation); return Result(roomInformation);
} catch (e) { } catch (e) {
return Result(InvalidDiscoInfoResponse); logger.warning('Invalid disco information: $e');
return Result(InvalidDiscoInfoResponse());
} }
} }
@@ -148,18 +164,23 @@ class MUCManager extends XmppManagerBase {
return Result(NoNicknameSpecified()); return Result(NoNicknameSpecified());
} }
await _cacheLock.synchronized( final completer =
await _cacheLock.synchronized<Completer<Result<bool, MUCError>>>(
() { () {
_mucRoomCache[roomJid] = RoomState( _mucRoomCache[roomJid] = RoomState(
roomJid: roomJid, roomJid: roomJid,
nick: nick, nick: nick,
joined: false, joined: false,
); );
final completer = Completer<Result<bool, MUCError>>();
_mucRoomJoinCompleter[roomJid] = completer;
return completer;
}, },
); );
await _sendMucJoin(roomJid, nick, maxHistoryStanzas); await _sendMucJoin(roomJid, nick, maxHistoryStanzas);
return const Result(true); return completer.future;
} }
Future<void> _sendMucJoin( Future<void> _sendMucJoin(
@@ -221,6 +242,184 @@ class MUCManager extends XmppManagerBase {
return const Result(true); 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( Future<StanzaHandlerData> _onMessageSent(
Stanza message, Stanza message,
StanzaHandlerData state, StanzaHandlerData state,
@@ -248,7 +447,8 @@ class MUCManager extends XmppManagerBase {
) async { ) async {
final fromJid = JID.fromString(message.from!); final fromJid = JID.fromString(message.from!);
final roomJid = fromJid.toBare(); final roomJid = fromJid.toBare();
return _mucRoomCache.synchronized(() { return _cacheLock.synchronized(() {
logger.finest('Lock aquired for message from ${message.from}');
final roomState = _mucRoomCache[roomJid]; final roomState = _mucRoomCache[roomJid];
if (roomState == null) { if (roomState == null) {
return state; return state;
@@ -259,6 +459,10 @@ class MUCManager extends XmppManagerBase {
if (!roomState.joined) { if (!roomState.joined) {
// Mark the room as joined. // Mark the room as joined.
_mucRoomCache[roomJid]!.joined = true; _mucRoomCache[roomJid]!.joined = true;
_mucRoomJoinCompleter[roomJid]!.complete(
const Result(true),
);
_mucRoomJoinCompleter.remove(roomJid);
logger.finest('$roomJid is now joined'); logger.finest('$roomJid is now joined');
} }
@@ -282,6 +486,9 @@ class MUCManager extends XmppManagerBase {
} }
// Check if this is the message reflection. // Check if this is the message reflection.
if (message.id == null) {
return state;
}
final pending = final pending =
(message.id!, state.extensions.get<StableIdData>()?.originId); (message.id!, state.extensions.get<StableIdData>()?.originId);
if (fromJid.resource == roomState.nick && if (fromJid.resource == roomState.nick &&

View File

@@ -56,27 +56,13 @@ class VCardManager extends XmppManagerBase {
final x = presence.firstTag('x', xmlns: vCardTempUpdate)!; final x = presence.firstTag('x', xmlns: vCardTempUpdate)!;
final hash = x.firstTag('photo')!.innerText(); final hash = x.firstTag('photo')!.innerText();
final from = JID.fromString(presence.from!).toBare(); getAttributes().sendEvent(
final lastHash = _lastHash[from]; VCardAvatarUpdatedEvent(
if (lastHash != hash) { JID.fromString(presence.from!),
_lastHash[from.toString()] = hash; hash,
final vcardResult = await requestVCard(from); ),
);
if (vcardResult.isType<VCard>()) { return state;
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;
} }
VCardPhoto? _parseVCardPhoto(XMLNode? node) { VCardPhoto? _parseVCardPhoto(XMLNode? node) {

View File

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

View File

@@ -75,6 +75,17 @@ class StreamManagementManager extends XmppManagerBase {
return acks; 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 /// Called when a stanza has been acked to decide whether we should trigger a
/// StanzaAckedEvent. /// StanzaAckedEvent.
/// ///
@@ -225,6 +236,12 @@ class StreamManagementManager extends XmppManagerBase {
_ackTimer = null; _ackTimer = null;
} }
/// Resets the ack timer.
void _resetAckTimer() {
_stopAckTimer();
_startAckTimer();
}
@visibleForTesting @visibleForTesting
Future<void> handleAckTimeout() async { Future<void> handleAckTimeout() async {
_stopAckTimer(); _stopAckTimer();
@@ -315,8 +332,7 @@ class StreamManagementManager extends XmppManagerBase {
// Reset the timer // Reset the timer
if (_pendingAcks > 0) { if (_pendingAcks > 0) {
_stopAckTimer(); _resetAckTimer();
_startAckTimer();
} }
} }

View File

@@ -4,6 +4,10 @@ class UnknownOmemoError extends OmemoError {}
class InvalidAffixElementsException implements 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 OmemoNotSupportedForContactException extends OmemoError {}
class EncryptionFailedException implements Exception {} class EncryptionFailedException implements Exception {}

View File

@@ -535,7 +535,10 @@ class OmemoManager extends XmppManagerBase {
final pm = getAttributes().getManagerById<PubSubManager>(pubsubManager)!; final pm = getAttributes().getManagerById<PubSubManager>(pubsubManager)!;
final result = await pm.getItems(jid.toBare(), omemoDevicesXmlns); final result = await pm.getItems(jid.toBare(), omemoDevicesXmlns);
if (result.isType<PubSubError>()) return Result(UnknownOmemoError()); 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]. /// Retrieves the OMEMO device list from [jid].

View File

@@ -107,40 +107,41 @@ class MessageRepliesManager extends XmppManagerBase {
TypedMap<StanzaHandlerExtension> extensions, TypedMap<StanzaHandlerExtension> extensions,
) { ) {
final data = extensions.get<ReplyData>(); final data = extensions.get<ReplyData>();
return data != null if (data == null) {
? [ return [];
XMLNode.xmlns( }
tag: 'reply', return [
xmlns: replyXmlns, XMLNode.xmlns(
attributes: { tag: 'reply',
// The to attribute is optional xmlns: replyXmlns,
if (data.jid != null) 'to': data.jid!.toString(), 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( Future<StanzaHandlerData> _onMessage(
@@ -154,7 +155,8 @@ class MessageRepliesManager extends XmppManagerBase {
int? end; int? end;
// TODO(Unknown): Maybe extend firstTag to also look for attributes // 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) { if (fallback != null) {
final body = fallback.firstTag('body')!; final body = fallback.firstTag('body')!;
start = int.parse(body.attributes['start']! as String); start = int.parse(body.attributes['start']! as String);

View File

@@ -274,16 +274,16 @@
<xmpp:version>0.2.0</xmpp:version> <xmpp:version>0.2.0</xmpp:version>
</xmpp:SupportedXep> </xmpp:SupportedXep>
</implements> </implements>
<!-- Non-Standard (Proto) XEPs -->
<implements> <implements>
<xmpp:SupportedXep> <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: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:note xml:lang="en">Invalidation is never requested</xmpp:note>
</xmpp:SupportedXep> </xmpp:SupportedXep>
</implements> </implements>
<!-- Non-Standard (Proto) XEPs -->
<implements> <implements>
<xmpp:SupportedXep> <xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://codeberg.org/moxxy/custom-xeps/src/branch/master/xep-xxxx-file-upload-notification.md"/> <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 publish_to: https://git.polynom.me/api/packages/Moxxy/pub
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: ">=3.0.0 <4.0.0"
dependencies: dependencies:
collection: ^1.16.0 collection: ^1.18.0
cryptography: ^2.0.5 cryptography: ^2.7.0
hex: ^0.2.0 hex: ^0.2.0
json_serializable: ^6.3.1 json_serializable: ^6.8.0
logging: ^1.0.2 logging: ^1.2.0
meta: ^1.7.0 meta: ^1.15.0
moxlib: moxlib:
hosted: https://git.polynom.me/api/packages/Moxxy/pub hosted: https://git.polynom.me/api/packages/Moxxy/pub
version: ^0.2.0 version: ^0.2.0
omemo_dart: omemo_dart:
hosted: https://git.polynom.me/api/packages/PapaTutuWawa/pub hosted: https://git.polynom.me/api/packages/PapaTutuWawa/pub
version: ^0.5.1 version: ^0.6.0
random_string: ^2.3.1 random_string: ^2.3.1
saslprep: ^1.0.2 saslprep: ^1.0.3
synchronized: ^3.0.0+2 synchronized: ^3.3.0+3
uuid: ^3.0.5 uuid: ^3.0.7
xml: ^6.1.0 xml: ^6.5.0
dev_dependencies: dev_dependencies:
build_runner: ^2.1.11 build_runner: ^2.4.12
test: ^1.18.0 test: ^1.25.8
very_good_analysis: ^3.0.1 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:moxxmpp/src/awaiter.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
void main() { const bareJid = 'user4@example.org';
const bareJid = JID('moxxmpp', 'server3.example', ''); String getBareJidCallback() => bareJid;
void main() {
test('Test awaiting an awaited stanza with a from attribute', () async { test('Test awaiting an awaited stanza with a from attribute', () async {
final awaiter = StanzaAwaiter(); final awaiter = StanzaAwaiter(getBareJidCallback);
// "Send" a stanza // "Send" a stanza
final future = await awaiter.addPending( final future = await awaiter.addPending(
@@ -20,14 +21,12 @@ void main() {
XMLNode.fromString( XMLNode.fromString(
'<iq from="user3@server.example" id="abc123" type="result" />', '<iq from="user3@server.example" id="abc123" type="result" />',
), ),
bareJid,
); );
expect(result1, false); expect(result1, false);
final result2 = await awaiter.onData( final result2 = await awaiter.onData(
XMLNode.fromString( XMLNode.fromString(
'<iq from="user1@server.example" id="lol" type="result" />', '<iq from="user1@server.example" id="lol" type="result" />',
), ),
bareJid,
); );
expect(result2, false); expect(result2, false);
@@ -37,22 +36,20 @@ void main() {
); );
final result3 = await awaiter.onData( final result3 = await awaiter.onData(
stanza, stanza,
bareJid,
); );
expect(result3, true); expect(result3, true);
expect(await future, stanza); expect(await future, stanza);
}); });
test('Test awaiting an awaited stanza without a from attribute', () async { test('Test awaiting an awaited stanza without a from attribute', () async {
final awaiter = StanzaAwaiter(); final awaiter = StanzaAwaiter(getBareJidCallback);
// "Send" a stanza // "Send" a stanza
final future = await awaiter.addPending(bareJid.toString(), 'abc123', 'iq'); final future = await awaiter.addPending(null, 'abc123', 'iq');
// Receive the wrong answer // Receive the wrong answer
final result1 = await awaiter.onData( final result1 = await awaiter.onData(
XMLNode.fromString('<iq id="lol" type="result" />'), XMLNode.fromString('<iq id="lol" type="result" />'),
bareJid,
); );
expect(result1, false); expect(result1, false);
@@ -60,23 +57,21 @@ void main() {
final stanza = XMLNode.fromString('<iq id="abc123" type="result" />'); final stanza = XMLNode.fromString('<iq id="abc123" type="result" />');
final result2 = await awaiter.onData( final result2 = await awaiter.onData(
stanza, stanza,
bareJid,
); );
expect(result2, true); expect(result2, true);
expect(await future, stanza); expect(await future, stanza);
}); });
test('Test awaiting a stanza that was already awaited', () async { test('Test awaiting a stanza that was already awaited', () async {
final awaiter = StanzaAwaiter(); final awaiter = StanzaAwaiter(getBareJidCallback);
// "Send" a stanza // "Send" a stanza
final future = await awaiter.addPending(bareJid.toString(), 'abc123', 'iq'); final future = await awaiter.addPending(null, 'abc123', 'iq');
// Receive the correct answer // Receive the correct answer
final stanza = XMLNode.fromString('<iq id="abc123" type="result" />'); final stanza = XMLNode.fromString('<iq id="abc123" type="result" />');
final result1 = await awaiter.onData( final result1 = await awaiter.onData(
stanza, stanza,
bareJid,
); );
expect(result1, true); expect(result1, true);
expect(await future, stanza); expect(await future, stanza);
@@ -84,31 +79,55 @@ void main() {
// Receive it again // Receive it again
final result2 = await awaiter.onData( final result2 = await awaiter.onData(
stanza, stanza,
bareJid,
); );
expect(result2, false); expect(result2, false);
}); });
test('Test ignoring a stanza that has the wrong tag', () async { test('Test ignoring a stanza that has the wrong tag', () async {
final awaiter = StanzaAwaiter(); final awaiter = StanzaAwaiter(getBareJidCallback);
// "Send" a stanza // "Send" a stanza
final future = await awaiter.addPending(bareJid.toString(), 'abc123', 'iq'); final future = await awaiter.addPending(null, 'abc123', 'iq');
// Receive the wrong answer // Receive the wrong answer
final stanza = XMLNode.fromString('<iq id="abc123" type="result" />'); final stanza = XMLNode.fromString('<iq id="abc123" type="result" />');
final result1 = await awaiter.onData( final result1 = await awaiter.onData(
XMLNode.fromString('<message id="abc123" type="result" />'), XMLNode.fromString('<message id="abc123" type="result" />'),
bareJid,
); );
expect(result1, false); expect(result1, false);
// Receive the correct answer // Receive the correct answer
final result2 = await awaiter.onData( final result2 = await awaiter.onData(
stanza, stanza,
bareJid,
); );
expect(result2, true); expect(result2, true);
expect(await future, stanza); 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"> <bind xmlns="urn:ietf:params:xml:ns:xmpp-bind">
<required/> <required/>
</bind> </bind>
<ver xmlns='urn:xmpp:features:rosterver'/>
</stream:features> </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

@@ -162,4 +162,713 @@ void main() {
expect(fakeSocket.getState(), 10); 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

@@ -343,6 +343,7 @@ void main() {
stanza, stanza,
StanzaHandlerData(false, false, stanza, TypedMap()), StanzaHandlerData(false, false, stanza, TypedMap()),
); );
await Future<void>.delayed(const Duration(seconds: 2));
expect( expect(
await manager.getCachedDiscoInfoFromJid(aliceJid) != null, await manager.getCachedDiscoInfoFromJid(aliceJid) != null,
@@ -513,6 +514,7 @@ void main() {
stanza, stanza,
StanzaHandlerData(false, false, stanza, TypedMap()), StanzaHandlerData(false, false, stanza, TypedMap()),
); );
await Future<void>.delayed(const Duration(seconds: 2));
final cachedItem = await manager.getCachedDiscoInfoFromJid(aliceJid); final cachedItem = await manager.getCachedDiscoInfoFromJid(aliceJid);
expect( expect(
@@ -549,6 +551,7 @@ void main() {
stanza, stanza,
StanzaHandlerData(false, false, stanza, TypedMap()), StanzaHandlerData(false, false, stanza, TypedMap()),
); );
await Future<void>.delayed(const Duration(seconds: 2));
final cachedItem = await manager.getCachedDiscoInfoFromJid(aliceJid); final cachedItem = await manager.getCachedDiscoInfoFromJid(aliceJid);
expect( expect(

View File

@@ -216,6 +216,89 @@ void main() {
expect(result.isType<XmppError>(), false); 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', test('Test SCRAM-SHA-1 SASL2 negotiation with an invalid signature',
() async { () async {
final fakeSocket = StubTCPSocket([ 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"> <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> <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' /> <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" /> <body start="0" end="38" />
</fallback> </fallback>
</message> </message>

View File

@@ -11,14 +11,16 @@ void main() {
final controller = StreamController<String>(); final controller = StreamController<String>();
unawaited( unawaited(
controller.stream.transform(parser).forEach((event) { controller.stream.transform(parser).forEach((events) {
if (event is! XMPPStreamElement) return; for (final event in events) {
final node = event.node; if (event is! XMPPStreamElement) continue;
final node = event.node;
if (node.tag == 'childa') { if (node.tag == 'childa') {
childa = true; childa = true;
} else if (node.tag == 'childb') { } else if (node.tag == 'childb') {
childb = true; childb = true;
}
} }
}), }),
); );
@@ -36,14 +38,16 @@ void main() {
final controller = StreamController<String>(); final controller = StreamController<String>();
unawaited( unawaited(
controller.stream.transform(parser).forEach((event) { controller.stream.transform(parser).forEach((events) {
if (event is! XMPPStreamElement) return; for (final event in events) {
final node = event.node; if (event is! XMPPStreamElement) continue;
final node = event.node;
if (node.tag == 'childa') { if (node.tag == 'childa') {
childa = true; childa = true;
} else if (node.tag == 'childb') { } else if (node.tag == 'childb') {
childb = true; childb = true;
}
} }
}), }),
); );
@@ -64,14 +68,16 @@ void main() {
final controller = StreamController<String>(); final controller = StreamController<String>();
unawaited( unawaited(
controller.stream.transform(parser).forEach((event) { controller.stream.transform(parser).forEach((events) {
if (event is! XMPPStreamElement) return; for (final event in events) {
final node = event.node; if (event is! XMPPStreamElement) continue;
final node = event.node;
if (node.tag == 'childa') { if (node.tag == 'childa') {
childa = true; childa = true;
} else if (node.tag == 'childb') { } else if (node.tag == 'childb') {
childb = true; childb = true;
}
} }
}), }),
); );
@@ -93,13 +99,15 @@ void main() {
final controller = StreamController<String>(); final controller = StreamController<String>();
unawaited( unawaited(
controller.stream.transform(parser).forEach((node) { controller.stream.transform(parser).forEach((events) {
if (node is XMPPStreamElement) { for (final event in events) {
if (node.node.tag == 'childa') { if (event is XMPPStreamElement) {
childa = true; 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; var gotFeatures = false;
unawaited( unawaited(
controller.stream.transform(parser).forEach( controller.stream.transform(parser).forEach(
(event) { (events) {
if (event is! XMPPStreamElement) return; for (final event in events) {
if (event is! XMPPStreamElement) continue;
if (event.node.tag == 'stream:features') { if (event.node.tag == 'stream:features') {
gotFeatures = true; gotFeatures = true;
}
} }
}, },
), ),
@@ -157,4 +167,27 @@ void main() {
await Future<void>.delayed(const Duration(seconds: 1)); await Future<void>.delayed(const Duration(seconds: 1));
expect(gotFeatures, true); 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);
});
} }

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 ## 0.3.1
- Keep version in sync with moxxmpp - Keep version in sync with moxxmpp

View File

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

View File

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

View File

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

View File

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

View File

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