Client feature add device tracking

This commit is contained in:
Christian Pauly 2020-02-04 13:41:13 +00:00
parent e1335ae2f3
commit edd8aa5c4c
12 changed files with 435 additions and 49 deletions

View file

@ -26,6 +26,7 @@ library famedlysdk;
export 'package:famedlysdk/src/sync/room_update.dart';
export 'package:famedlysdk/src/sync/event_update.dart';
export 'package:famedlysdk/src/sync/user_update.dart';
export 'package:famedlysdk/src/utils/device_keys_list.dart';
export 'package:famedlysdk/src/utils/matrix_exception.dart';
export 'package:famedlysdk/src/utils/matrix_file.dart';
export 'package:famedlysdk/src/utils/mx_content.dart';

View file

@ -29,6 +29,7 @@ import 'package:famedlysdk/src/account_data.dart';
import 'package:famedlysdk/src/presence.dart';
import 'package:famedlysdk/src/store_api.dart';
import 'package:famedlysdk/src/sync/user_update.dart';
import 'package:famedlysdk/src/utils/device_keys_list.dart';
import 'package:famedlysdk/src/utils/matrix_file.dart';
import 'package:famedlysdk/src/utils/open_id_credentials.dart';
import 'package:famedlysdk/src/utils/turn_server_credentials.dart';
@ -540,6 +541,12 @@ class Client {
}
static String syncFilters = '{"room":{"state":{"lazy_load_members":true}}}';
static const List<String> supportedDirectEncryptionAlgorithms = [
"m.olm.v1.curve25519-aes-sha2"
];
static const List<String> supportedGroupEncryptionAlgorithms = [
"m.megolm.v1.aes-sha2"
];
http.Client httpClient = http.Client();
@ -630,15 +637,16 @@ class Client {
/// ```
///
/// Sends [LoginState.logged] to [onLoginStateChanged].
void connect(
{String newToken,
String newHomeserver,
String newUserID,
String newDeviceName,
String newDeviceID,
List<String> newMatrixVersions,
bool newLazyLoadMembers,
String newPrevBatch}) async {
void connect({
String newToken,
String newHomeserver,
String newUserID,
String newDeviceName,
String newDeviceID,
List<String> newMatrixVersions,
bool newLazyLoadMembers,
String newPrevBatch,
}) async {
this._accessToken = newToken;
this._homeserver = newHomeserver;
this._userID = newUserID;
@ -650,6 +658,7 @@ class Client {
if (this.storeAPI != null) {
await this.storeAPI.storeClient();
_userDeviceKeys = await this.storeAPI.getUserDeviceKeys();
if (this.store != null) {
this._rooms = await this.store.getRoomList(onlyLeft: false);
this._sortRooms();
@ -833,6 +842,7 @@ class Client {
_sortRooms();
}
this.prevBatch = syncResp["next_batch"];
unawaited(_updateUserDeviceKeys());
if (hash == _syncRequest.hashCode) unawaited(_sync());
} on MatrixException catch (exception) {
onError.add(exception);
@ -866,9 +876,29 @@ class Client {
sync["to_device"]["events"] is List<dynamic>) {
_handleGlobalEvents(sync["to_device"]["events"], "to_device");
}
if (sync["device_lists"] is Map<String, dynamic>) {
_handleDeviceListsEvents(sync["device_lists"]);
}
onSync.add(sync);
}
void _handleDeviceListsEvents(Map<String, dynamic> deviceLists) {
if (deviceLists["changed"] is List) {
for (final userId in deviceLists["changed"]) {
print("The device list of $userId has changed. Mark as outdated!");
if (_userDeviceKeys.containsKey(userId)) {
_userDeviceKeys[userId].outdated = true;
}
}
for (final userId in deviceLists["left"]) {
print("The device list of $userId is no longer relevant! Remove it!");
if (_userDeviceKeys.containsKey(userId)) {
_userDeviceKeys.remove(userId);
}
}
}
}
void _handleRooms(Map<String, dynamic> rooms, Membership membership) {
rooms.forEach((String id, dynamic room) async {
// calculate the notification counts, the limitedTimeline and prevbatch
@ -1011,6 +1041,13 @@ class Client {
void _handleEvent(Map<String, dynamic> event, String roomID, String type) {
if (event["type"] is String && event["content"] is Map<String, dynamic>) {
// The client must ignore any new m.room.encryption event to prevent
// man-in-the-middle attacks!
if (event["type"] == "m.room.encryption" &&
getRoomById(roomID).encrypted) {
return;
}
EventUpdate update = EventUpdate(
eventType: event["type"],
roomID: roomID,
@ -1162,4 +1199,65 @@ class Client {
);
return OpenIdCredentials.fromJson(response);
}
/// A map of known device keys per user.
Map<String, DeviceKeysList> get userDeviceKeys => _userDeviceKeys;
Map<String, DeviceKeysList> _userDeviceKeys = {};
Future<Set<String>> _getUserIdsInEncryptedRooms() async {
Set<String> userIds = {};
for (int i = 0; i < rooms.length; i++) {
if (rooms[i].encrypted) {
List<User> userList = await rooms[i].requestParticipants();
for (User user in userList) {
userIds.add(user.id);
}
}
}
return userIds;
}
Future<void> _updateUserDeviceKeys() async {
Set<String> trackedUserIds = await _getUserIdsInEncryptedRooms();
print("We are tracking the devices of these users:");
print(trackedUserIds);
// Remove all userIds we no longer need to track the devices of.
_userDeviceKeys
.removeWhere((String userId, v) => !trackedUserIds.contains(userId));
// Check if there are outdated device key lists. Add it to the set.
Map<String, dynamic> outdatedLists = {};
for (String userId in trackedUserIds) {
if (!userDeviceKeys.containsKey(userId)) {
print("Create new device list for user $userId");
_userDeviceKeys[userId] = DeviceKeysList(userId);
}
DeviceKeysList deviceKeysList = userDeviceKeys[userId];
if (deviceKeysList.outdated) {
print(
"The device keys list of $userId is outdated. Add to the request");
outdatedLists[userId] = [];
}
}
// Request the missing device key lists.
if (outdatedLists.isNotEmpty) {
final Map<String, dynamic> response = await this.jsonRequest(
type: HTTPType.POST,
action: "/client/r0/keys/query",
data: {"timeout": 10000, "device_keys": outdatedLists});
for (final rawDeviceKeyListEntry in response["device_keys"].entries) {
final String userId = rawDeviceKeyListEntry.key;
_userDeviceKeys[userId].deviceKeys = {};
print("Got device key list of $userId. Store it now!");
for (final rawDeviceKeyEntry in rawDeviceKeyListEntry.value.entries) {
_userDeviceKeys[userId].deviceKeys[rawDeviceKeyEntry.key] =
DeviceKeys.fromJson(rawDeviceKeyEntry.value);
}
_userDeviceKeys[userId].outdated = false;
}
}
await this.storeAPI?.storeUserDeviceKeys(userDeviceKeys);
}
}

View file

@ -208,9 +208,9 @@ class Event {
return EventTypes.Sticker;
case "m.room.message":
return EventTypes.Message;
case "m.call.encrypted":
case "m.room.encrypted":
return EventTypes.Encrypted;
case "m.call.encryption":
case "m.room.encryption":
return EventTypes.Encryption;
case "m.call.invite":
return EventTypes.CallInvite;

View file

@ -23,6 +23,7 @@
import 'dart:async';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:famedlysdk/src/client.dart';
import 'package:famedlysdk/src/event.dart';
import 'package:famedlysdk/src/room_account_data.dart';
@ -761,6 +762,7 @@ class Room {
/// Request the full list of participants from the server. The local list
/// from the store is not complete if the client uses lazy loading.
Future<List<User>> requestParticipants() async {
if (participantListComplete) return getParticipants();
List<User> participants = [];
dynamic res = await client.jsonRequest(
@ -768,12 +770,24 @@ class Room {
for (num i = 0; i < res["chunk"].length; i++) {
User newUser = Event.fromJson(res["chunk"][i], this).asUser;
if (newUser.membership != Membership.leave) participants.add(newUser);
if (![Membership.leave, Membership.ban].contains(newUser.membership)) {
participants.add(newUser);
setState(newUser);
}
}
return participants;
}
/// Checks if the local participant list of joined and invited users is complete.
bool get participantListComplete {
List<User> knownParticipants = getParticipants();
knownParticipants.removeWhere(
(u) => ![Membership.join, Membership.invite].contains(u.membership));
return knownParticipants.length ==
(this.mJoinedMemberCount ?? 0) + (this.mInvitedMemberCount ?? 0);
}
/// Returns the [User] object for the given [mxID] or requests it from
/// the homeserver and waits for a response.
Future<User> getUserByMXID(String mxID) async {
@ -1235,4 +1249,42 @@ class Room {
/// Whether the user has the permission to change the history visibility.
bool get canChangeHistoryVisibility =>
canSendEvent("m.room.history_visibility");
/// Returns the encryption algorithm. Currently only `m.megolm.v1.aes-sha2` is supported.
/// Returns null if there is no encryption algorithm.
String get encryptionAlgorithm => getState("m.room.encryption") != null
? getState("m.room.encryption").content["algorithm"].toString()
: null;
/// Checks if this room is encrypted.
bool get encrypted => encryptionAlgorithm != null;
Future<void> enableEncryption({int algorithmIndex = 0}) async {
if (encrypted) throw ("Encryption is already enabled!");
final String algorithm =
Client.supportedGroupEncryptionAlgorithms[algorithmIndex];
await client.jsonRequest(
type: HTTPType.PUT,
action: "/client/r0/rooms/$id/state/m.room.encryption/",
data: {
"algorithm": algorithm,
},
);
return;
}
Future<List<DeviceKeys>> getUserDeviceKeys() async {
List<DeviceKeys> deviceKeys = [];
List<User> users = await requestParticipants();
for (final userDeviceKeyEntry in client.userDeviceKeys.entries) {
if (users.indexWhere((u) => u.id == userDeviceKeyEntry.key) == -1) {
continue;
}
for (DeviceKeys deviceKeyEntry
in userDeviceKeyEntry.value.deviceKeys.values) {
deviceKeys.add(deviceKeyEntry);
}
}
return deviceKeys;
}
}

View file

@ -25,6 +25,7 @@ import 'dart:async';
import 'dart:core';
import 'package:famedlysdk/src/account_data.dart';
import 'package:famedlysdk/src/presence.dart';
import 'package:famedlysdk/src/utils/device_keys_list.dart';
import 'client.dart';
import 'event.dart';
import 'room.dart';
@ -46,6 +47,10 @@ abstract class StoreAPI {
/// Clears all tables from the database.
Future<void> clear();
Future<void> storeUserDeviceKeys(Map<String, DeviceKeysList> userDeviceKeys);
Future<Map<String, DeviceKeysList>> getUserDeviceKeys();
}
/// Responsible to store all data persistent and to query objects from the

View file

@ -0,0 +1,102 @@
import 'dart:convert';
import '../client.dart';
class DeviceKeysList {
String userId;
bool outdated = true;
Map<String, DeviceKeys> deviceKeys = {};
DeviceKeysList.fromJson(Map<String, dynamic> json) {
userId = json['user_id'];
outdated = json['outdated'];
deviceKeys = {};
for (final rawDeviceKeyEntry in json['device_keys'].entries) {
deviceKeys[rawDeviceKeyEntry.key] =
DeviceKeys.fromJson(rawDeviceKeyEntry.value);
}
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = Map<String, dynamic>();
data['user_id'] = this.userId;
data['outdated'] = this.outdated;
Map<String, dynamic> rawDeviceKeys = {};
for (final deviceKeyEntry in this.deviceKeys.entries) {
rawDeviceKeys[deviceKeyEntry.key] = deviceKeyEntry.value.toJson();
}
data['device_keys'] = rawDeviceKeys;
return data;
}
String toString() => json.encode(toJson());
DeviceKeysList(this.userId);
}
class DeviceKeys {
String userId;
String deviceId;
List<String> algorithms;
Map<String, String> keys;
Map<String, dynamic> signatures;
Map<String, dynamic> unsigned;
bool verified;
bool blocked;
Future<void> setVerified(bool newVerified, Client client) {
verified = newVerified;
return client.storeAPI.storeUserDeviceKeys(client.userDeviceKeys);
}
Future<void> setBlocked(bool newBlocked, Client client) {
blocked = newBlocked;
return client.storeAPI.storeUserDeviceKeys(client.userDeviceKeys);
}
DeviceKeys({
this.userId,
this.deviceId,
this.algorithms,
this.keys,
this.signatures,
this.unsigned,
this.verified,
this.blocked,
});
DeviceKeys.fromJson(Map<String, dynamic> json) {
userId = json['user_id'];
deviceId = json['device_id'];
algorithms = json['algorithms'].cast<String>();
keys = json['keys'] != null ? Map<String, String>.from(json['keys']) : null;
signatures = json['signatures'] != null
? Map<String, dynamic>.from(json['signatures'])
: null;
unsigned = json['unsigned'] != null
? Map<String, dynamic>.from(json['unsigned'])
: null;
verified = json['verified'] ?? false;
blocked = json['blocked'] ?? false;
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = Map<String, dynamic>();
data['user_id'] = this.userId;
data['device_id'] = this.deviceId;
data['algorithms'] = this.algorithms;
if (this.keys != null) {
data['keys'] = this.keys;
}
if (this.signatures != null) {
data['signatures'] = this.signatures;
}
if (this.unsigned != null) {
data['unsigned'] = this.unsigned;
}
data['verified'] = this.verified;
data['blocked'] = this.blocked;
return data;
}
}

View file

@ -212,19 +212,12 @@ packages:
source: hosted
version: "0.6.1+1"
json_annotation:
dependency: "direct main"
dependency: transitive
description:
name: json_annotation
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
kernel:
dependency: transitive
description:
@ -365,13 +358,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.3"
source_gen:
dependency: transitive
description:
name: source_gen
url: "https://pub.dartlang.org"
source: hosted
version: "0.9.4+2"
source_map_stack_trace:
dependency: transitive
description:

View file

@ -10,10 +10,8 @@ environment:
dependencies:
http: ^0.12.0+2
mime_type: ^0.2.4
json_annotation: ^2.4.0
dev_dependencies:
test: ^1.0.0
build_runner: ^1.5.2
json_serializable: ^3.0.0
pedantic: ^1.5.0 # DO NOT UPDATE AS THIS WOULD CAUSE FLUTTER TO FAIL

View file

@ -138,6 +138,9 @@ void main() {
expect(matrix.rooms[1].typingUsers.length, 1);
expect(matrix.rooms[1].typingUsers[0].id, "@alice:example.com");
expect(matrix.rooms[1].roomAccountData.length, 3);
expect(matrix.rooms[1].encrypted, true);
expect(matrix.rooms[1].encryptionAlgorithm,
Client.supportedGroupEncryptionAlgorithms.first);
expect(
matrix.rooms[1].roomAccountData["m.receipt"]
.content["@alice:example.com"]["ts"],
@ -151,11 +154,33 @@ void main() {
"#famedlyContactDiscovery:${matrix.userID.split(":")[1]}");
final List<User> contacts = await matrix.loadFamedlyContacts();
expect(contacts.length, 1);
expect(contacts[0].senderId, "@alice:example.org");
expect(contacts[0].senderId, "@alice:example.com");
expect(
matrix.presences["@alice:example.com"].presence, PresenceType.online);
expect(presenceCounter, 1);
expect(accountDataCounter, 3);
await Future.delayed(Duration(milliseconds: 50));
expect(matrix.userDeviceKeys.length, 1);
expect(matrix.userDeviceKeys["@alice:example.com"].outdated, false);
expect(matrix.userDeviceKeys["@alice:example.com"].deviceKeys.length, 1);
expect(
matrix.userDeviceKeys["@alice:example.com"].deviceKeys["JLAFKJWSCS"]
.verified,
false);
matrix.handleSync({
"device_lists": {
"changed": [
"@alice:example.com",
],
"left": [
"@bob:example.com",
],
}
});
await Future.delayed(Duration(milliseconds: 50));
expect(matrix.userDeviceKeys.length, 1);
expect(matrix.userDeviceKeys["@alice:example.com"].outdated, true);
matrix.handleSync({
"rooms": {
@ -184,6 +209,7 @@ void main() {
"#famedlyContactDiscovery:${matrix.userID.split(":")[1]}"),
null);
final List<User> altContacts = await matrix.loadFamedlyContacts();
altContacts.forEach((u) => print(u.id));
expect(altContacts.length, 2);
expect(altContacts[0].senderId, "@alice:example.com");
});
@ -248,7 +274,7 @@ void main() {
List<EventUpdate> eventUpdateList = await eventUpdateListFuture;
expect(eventUpdateList.length, 12);
expect(eventUpdateList.length, 13);
expect(eventUpdateList[0].eventType, "m.room.member");
expect(eventUpdateList[0].roomID, "!726s6s6q:example.com");
@ -258,41 +284,45 @@ void main() {
expect(eventUpdateList[1].roomID, "!726s6s6q:example.com");
expect(eventUpdateList[1].type, "state");
expect(eventUpdateList[2].eventType, "m.room.member");
expect(eventUpdateList[2].eventType, "m.room.encryption");
expect(eventUpdateList[2].roomID, "!726s6s6q:example.com");
expect(eventUpdateList[2].type, "timeline");
expect(eventUpdateList[2].type, "state");
expect(eventUpdateList[3].eventType, "m.room.message");
expect(eventUpdateList[3].eventType, "m.room.member");
expect(eventUpdateList[3].roomID, "!726s6s6q:example.com");
expect(eventUpdateList[3].type, "timeline");
expect(eventUpdateList[4].eventType, "m.typing");
expect(eventUpdateList[4].eventType, "m.room.message");
expect(eventUpdateList[4].roomID, "!726s6s6q:example.com");
expect(eventUpdateList[4].type, "ephemeral");
expect(eventUpdateList[4].type, "timeline");
expect(eventUpdateList[5].eventType, "m.receipt");
expect(eventUpdateList[5].eventType, "m.typing");
expect(eventUpdateList[5].roomID, "!726s6s6q:example.com");
expect(eventUpdateList[5].type, "ephemeral");
expect(eventUpdateList[6].eventType, "m.receipt");
expect(eventUpdateList[6].roomID, "!726s6s6q:example.com");
expect(eventUpdateList[6].type, "account_data");
expect(eventUpdateList[6].type, "ephemeral");
expect(eventUpdateList[7].eventType, "m.tag");
expect(eventUpdateList[7].eventType, "m.receipt");
expect(eventUpdateList[7].roomID, "!726s6s6q:example.com");
expect(eventUpdateList[7].type, "account_data");
expect(eventUpdateList[8].eventType, "org.example.custom.room.config");
expect(eventUpdateList[8].eventType, "m.tag");
expect(eventUpdateList[8].roomID, "!726s6s6q:example.com");
expect(eventUpdateList[8].type, "account_data");
expect(eventUpdateList[9].eventType, "m.room.name");
expect(eventUpdateList[9].roomID, "!696r7674:example.com");
expect(eventUpdateList[9].type, "invite_state");
expect(eventUpdateList[9].eventType, "org.example.custom.room.config");
expect(eventUpdateList[9].roomID, "!726s6s6q:example.com");
expect(eventUpdateList[9].type, "account_data");
expect(eventUpdateList[10].eventType, "m.room.member");
expect(eventUpdateList[10].eventType, "m.room.name");
expect(eventUpdateList[10].roomID, "!696r7674:example.com");
expect(eventUpdateList[10].type, "invite_state");
expect(eventUpdateList[11].eventType, "m.room.member");
expect(eventUpdateList[11].roomID, "!696r7674:example.com");
expect(eventUpdateList[11].type, "invite_state");
});
test('User Update Test', () async {

View file

@ -0,0 +1,78 @@
/*
* Copyright (c) 2019 Zender & Kurtz GbR.
*
* Authors:
* Christian Pauly <krille@famedly.com>
* Marcel Radzio <mtrnord@famedly.com>
*
* This file is part of famedlysdk.
*
* famedlysdk is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* famedlysdk is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with famedlysdk. If not, see <http://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:test/test.dart';
void main() {
/// All Tests related to device keys
group("Device keys", () {
test("fromJson", () async {
Map<String, dynamic> rawJson = {
"user_id": "@alice:example.com",
"device_id": "JLAFKJWSCS",
"algorithms": ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
"keys": {
"curve25519:JLAFKJWSCS":
"3C5BFWi2Y8MaVvjM8M22DBmh24PmgR0nPvJOIArzgyI",
"ed25519:JLAFKJWSCS": "lEuiRJBit0IG6nUf5pUzWTUEsRVVe/HJkoKuEww9ULI"
},
"signatures": {
"@alice:example.com": {
"ed25519:JLAFKJWSCS":
"dSO80A01XiigH3uBiDVx/EjzaoycHcjq9lfQX0uWsqxl2giMIiSPR8a4d291W1ihKJL/a+myXS367WT6NAIcBA"
}
},
"unsigned": {"device_display_name": "Alice's mobile phone"},
"verified": false,
"blocked": true,
};
Map<String, dynamic> rawListJson = {
"user_id": "@alice:example.com",
"outdated": true,
"device_keys": {"JLAFKJWSCS": rawJson},
};
Map<String, DeviceKeysList> userDeviceKeys = {
"@alice:example.com": DeviceKeysList.fromJson(rawListJson),
};
Map<String, dynamic> userDeviceKeyRaw = {
"@alice:example.com": rawListJson,
};
expect(json.encode(DeviceKeys.fromJson(rawJson).toJson()),
json.encode(rawJson));
expect(json.encode(DeviceKeysList.fromJson(rawListJson).toJson()),
json.encode(rawListJson));
Map<String, DeviceKeysList> mapFromRaw = {};
for (final rawListEntry in userDeviceKeyRaw.entries) {
mapFromRaw[rawListEntry.key] =
DeviceKeysList.fromJson(rawListEntry.value);
}
expect(mapFromRaw.toString(), userDeviceKeys.toString());
});
});
}

View file

@ -364,7 +364,15 @@ class FakeMatrixApi extends MockClient {
"state_key": "",
"origin_server_ts": 1417731086796,
"event_id": "66697273743032:example.com"
}
},
{
"sender": "@alice:example.com",
"type": "m.room.encryption",
"state_key": "",
"content": {"algorithm": "m.megolm.v1.aes-sha2"},
"origin_server_ts": 1417731086795,
"event_id": "666972737430353:example.com"
},
]
},
"timeline": {
@ -582,10 +590,10 @@ class FakeMatrixApi extends MockClient {
"type": "m.room.member",
"event_id": "§143273582443PhrSn:example.org",
"room_id": "!636q39766251:example.com",
"sender": "@alice:example.org",
"sender": "@alice:example.com",
"origin_server_ts": 1432735824653,
"unsigned": {"age": 1234},
"state_key": "@alice:example.org"
"state_key": "@alice:example.com"
}
]
},
@ -760,6 +768,34 @@ class FakeMatrixApi extends MockClient {
{"available": true},
},
"POST": {
"/client/r0/keys/query": (var req) => {
"failures": {},
"device_keys": {
"@alice:example.com": {
"JLAFKJWSCS": {
"user_id": "@alice:example.com",
"device_id": "JLAFKJWSCS",
"algorithms": [
"m.olm.v1.curve25519-aes-sha2",
"m.megolm.v1.aes-sha2"
],
"keys": {
"curve25519:JLAFKJWSCS":
"3C5BFWi2Y8MaVvjM8M22DBmh24PmgR0nPvJOIArzgyI",
"ed25519:JLAFKJWSCS":
"lEuiRJBit0IG6nUf5pUzWTUEsRVVe/HJkoKuEww9ULI"
},
"signatures": {
"@alice:example.com": {
"ed25519:JLAFKJWSCS":
"dSO80A01XiigH3uBiDVx/EjzaoycHcjq9lfQX0uWsqxl2giMIiSPR8a4d291W1ihKJL/a+myXS367WT6NAIcBA"
}
},
"unsigned": {"device_display_name": "Alice's mobile phone"}
}
}
}
},
"/client/r0/register": (var req) => {"user_id": "@testuser:example.com"},
"/client/r0/register?kind=user": (var req) =>
{"user_id": "@testuser:example.com"},

View file

@ -298,8 +298,8 @@ void main() {
content: {"displayname": "alice"},
stateKey: "@alice:test.abc"));
final List<User> userList = room.getParticipants();
expect(userList.length, 4);
expect(userList[3].displayName, "alice");
expect(userList.length, 5);
expect(userList[3].displayName, "Alice Margatroid");
});
test("addToDirectChat", () async {