The famedly SDK only knows /sync, but its sync loop asks Client.sync for each response. WarpClient answers that with a sliding sync request and rewrites the response into the /sync shape (slidingToSyncJson), so decryption, the database, timelines and notifications work unchanged. - Two lists, rooms and direct chats (with all their members, so DM names come out right), over a window that starts at the 20 most recent rooms for a quick first screen and widens to every room (up to 1000). - The state the screens and encryption need, $LAZY members and $ME, and call memberships; timeline_limit 10, with prev_batch for history. - Extensions: to_device (its since rides in the token), e2ee, account data, receipts, typing. - Rooms the user left while the connection was open become leaves; invite_state and knock_state become invites and knocks. - The SDK's token is ss1|<pos>|<to-device since>. A classic token, none, or M_UNKNOWN_POS starts a new connection; a server without org.matrix.simplified_msc3575 gets plain /sync. Tested against Warp: first sync, encryption setup (account data and oneShotSync through the connection), live messages, unread counts and typing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
7.5 KiB
Dart
241 lines
7.5 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
import 'package:warp/matrix/sliding_sync.dart';
|
|
|
|
const me = '@alice:example.org';
|
|
|
|
Map<String, Object?> member(String userId, String membership, {int ts = 1}) =>
|
|
{
|
|
'type': 'm.room.member',
|
|
'state_key': userId,
|
|
'sender': userId,
|
|
'event_id': '\$m$ts$membership',
|
|
'origin_server_ts': ts,
|
|
'content': {'membership': membership},
|
|
};
|
|
|
|
Map<String, Object?> message(String body, {int ts = 2}) => {
|
|
'type': 'm.room.message',
|
|
'sender': '@bob:example.org',
|
|
'event_id': '\$msg$ts',
|
|
'origin_server_ts': ts,
|
|
'content': {'msgtype': 'm.text', 'body': body},
|
|
};
|
|
|
|
void main() {
|
|
group('SlidingToken', () {
|
|
test('no token is a new connection', () {
|
|
final t = SlidingToken.parse(null);
|
|
expect(t.pos, isNull);
|
|
expect(t.toDeviceSince, isNull);
|
|
});
|
|
|
|
test('a /sync token is a new connection', () {
|
|
expect(SlidingToken.parse('s72594_4483_1934').pos, isNull);
|
|
});
|
|
|
|
test('round trip', () {
|
|
final t = SlidingToken.parse(const SlidingToken('42', 'td7').toString());
|
|
expect(t.pos, '42');
|
|
expect(t.toDeviceSince, 'td7');
|
|
});
|
|
|
|
test('a token without a to-device position', () {
|
|
final t = SlidingToken.parse(const SlidingToken('3', null).toString());
|
|
expect(t.pos, '3');
|
|
expect(t.toDeviceSince, isNull);
|
|
});
|
|
});
|
|
|
|
group('slidingRequest', () {
|
|
test('asks for both lists over the window, with the extensions', () {
|
|
final body = slidingRequest(window: 20, toDeviceSince: 'x', connId: 'c');
|
|
final lists = body['lists'] as Map;
|
|
expect((lists['rooms'] as Map)['ranges'], [
|
|
[0, 19],
|
|
]);
|
|
expect(((lists['dms'] as Map)['filters'] as Map)['is_dm'], true);
|
|
final ext = body['extensions'] as Map;
|
|
expect((ext['to_device'] as Map)['since'], 'x');
|
|
expect((ext['e2ee'] as Map)['enabled'], true);
|
|
});
|
|
});
|
|
|
|
group('slidingToSyncJson', () {
|
|
test('a joined room becomes a join with state, timeline and counts', () {
|
|
final json = slidingToSyncJson(
|
|
{
|
|
'pos': '1',
|
|
'rooms': {
|
|
'!a:x': {
|
|
'required_state': [member(me, 'join')],
|
|
'timeline': [message('hi')],
|
|
'prev_batch': 'p1',
|
|
'limited': true,
|
|
'joined_count': 2,
|
|
'notification_count': 3,
|
|
'highlight_count': 1,
|
|
'heroes': [
|
|
{'user_id': '@bob:example.org', 'displayname': 'Bob'},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
userId: me,
|
|
nextBatch: 'nb',
|
|
);
|
|
final sync = SyncUpdate.fromJson(json);
|
|
expect(sync.nextBatch, 'nb');
|
|
final room = sync.rooms!.join!['!a:x']!;
|
|
expect(room.state!.single.type, 'm.room.member');
|
|
expect(room.timeline!.events!.single.content['body'], 'hi');
|
|
expect(room.timeline!.limited, true);
|
|
expect(room.timeline!.prevBatch, 'p1');
|
|
expect(room.unreadNotifications!.notificationCount, 3);
|
|
expect(room.unreadNotifications!.highlightCount, 1);
|
|
expect(room.summary!.mJoinedMemberCount, 2);
|
|
expect(room.summary!.mHeroes, ['@bob:example.org']);
|
|
});
|
|
|
|
test('invite and knock state', () {
|
|
final json = slidingToSyncJson(
|
|
{
|
|
'rooms': {
|
|
'!i:x': {
|
|
'invite_state': [
|
|
{
|
|
'type': 'm.room.member',
|
|
'state_key': me,
|
|
'sender': '@bob:example.org',
|
|
'content': {'membership': 'invite'},
|
|
},
|
|
],
|
|
},
|
|
'!k:x': {'knock_state': <Object?>[]},
|
|
},
|
|
},
|
|
userId: me,
|
|
nextBatch: 'nb',
|
|
);
|
|
final sync = SyncUpdate.fromJson(json);
|
|
expect(sync.rooms!.invite!['!i:x']!.inviteState!.single.stateKey, me);
|
|
expect(sync.rooms!.knock!.containsKey('!k:x'), true);
|
|
expect(sync.rooms!.join, isNull);
|
|
});
|
|
|
|
test('a room the user left is a leave', () {
|
|
final json = slidingToSyncJson(
|
|
{
|
|
'rooms': {
|
|
'!l:x': {
|
|
'required_state': [member(me, 'join', ts: 1)],
|
|
'timeline': [member(me, 'leave', ts: 5)],
|
|
},
|
|
},
|
|
},
|
|
userId: me,
|
|
nextBatch: 'nb',
|
|
);
|
|
final sync = SyncUpdate.fromJson(json);
|
|
expect(sync.rooms!.leave!.containsKey('!l:x'), true);
|
|
expect(sync.rooms!.join, isNull);
|
|
});
|
|
|
|
test('a rejoin after a leave is a join', () {
|
|
final json = slidingToSyncJson(
|
|
{
|
|
'rooms': {
|
|
'!r:x': {
|
|
'timeline': [
|
|
member(me, 'leave', ts: 1),
|
|
member(me, 'join', ts: 2),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
userId: me,
|
|
nextBatch: 'nb',
|
|
);
|
|
expect(SyncUpdate.fromJson(json).rooms!.join!.containsKey('!r:x'), true);
|
|
});
|
|
|
|
test('extensions: to-device, keys, account data, typing, receipts', () {
|
|
final json = slidingToSyncJson(
|
|
{
|
|
'rooms': {
|
|
'!a:x': {'timeline': [message('hi')]},
|
|
},
|
|
'extensions': {
|
|
'to_device': {
|
|
'next_batch': 't2',
|
|
'events': [
|
|
{
|
|
'type': 'm.room_key_request',
|
|
'sender': '@bob:example.org',
|
|
'content': <String, Object?>{},
|
|
},
|
|
],
|
|
},
|
|
'e2ee': {
|
|
'device_lists': {
|
|
'changed': ['@bob:example.org'],
|
|
'left': <String>[],
|
|
},
|
|
'device_one_time_keys_count': {'signed_curve25519': 50},
|
|
'device_unused_fallback_key_types': ['signed_curve25519'],
|
|
},
|
|
'account_data': {
|
|
'global': [
|
|
{'type': 'm.direct', 'content': <String, Object?>{}},
|
|
],
|
|
'rooms': {
|
|
'!a:x': [
|
|
{'type': 'm.fully_read', 'content': {'event_id': '\$x'}},
|
|
],
|
|
},
|
|
},
|
|
'typing': {
|
|
'rooms': {
|
|
// A room with no other news still gets its typing list.
|
|
'!quiet:x': {
|
|
'type': 'm.typing',
|
|
'content': {
|
|
'user_ids': ['@bob:example.org'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
'receipts': {
|
|
'rooms': {
|
|
'!a:x': {'type': 'm.receipt', 'content': <String, Object?>{}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
userId: me,
|
|
nextBatch: 'nb',
|
|
);
|
|
final sync = SyncUpdate.fromJson(json);
|
|
expect(sync.toDevice!.single.type, 'm.room_key_request');
|
|
expect(sync.deviceLists!.changed, ['@bob:example.org']);
|
|
expect(sync.deviceOneTimeKeysCount!['signed_curve25519'], 50);
|
|
expect(sync.deviceUnusedFallbackKeyTypes, ['signed_curve25519']);
|
|
expect(sync.accountData!.single.type, 'm.direct');
|
|
final a = sync.rooms!.join!['!a:x']!;
|
|
expect(a.accountData!.single.type, 'm.fully_read');
|
|
expect(a.ephemeral!.single.type, 'm.receipt');
|
|
final quiet = sync.rooms!.join!['!quiet:x']!;
|
|
expect(quiet.ephemeral!.single.type, 'm.typing');
|
|
});
|
|
|
|
test('an empty response is just a token', () {
|
|
final json = slidingToSyncJson(
|
|
{'pos': '9', 'rooms': <String, Object?>{}},
|
|
userId: me,
|
|
nextBatch: 'nb',
|
|
);
|
|
expect(json, {'next_batch': 'nb'});
|
|
});
|
|
});
|
|
}
|