1691 lines
59 KiB
Dart
1691 lines
59 KiB
Dart
/*
|
||
* 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:async';
|
||
import 'dart:convert';
|
||
|
||
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';
|
||
import 'package:famedlysdk/src/sync/event_update.dart';
|
||
import 'package:famedlysdk/src/sync/room_update.dart';
|
||
import 'package:famedlysdk/src/utils/matrix_exception.dart';
|
||
import 'package:famedlysdk/src/utils/matrix_file.dart';
|
||
import 'package:famedlysdk/src/utils/mx_content.dart';
|
||
import 'package:famedlysdk/src/utils/session_key.dart';
|
||
import 'package:matrix_file_e2ee/matrix_file_e2ee.dart';
|
||
import 'package:mime_type/mime_type.dart';
|
||
import 'package:olm/olm.dart' as olm;
|
||
import 'package:pedantic/pedantic.dart';
|
||
|
||
import './user.dart';
|
||
import 'timeline.dart';
|
||
import 'utils/states_map.dart';
|
||
|
||
enum PushRuleState { notify, mentions_only, dont_notify }
|
||
enum JoinRules { public, knock, invite, private }
|
||
enum GuestAccess { can_join, forbidden }
|
||
enum HistoryVisibility { invited, joined, shared, world_readable }
|
||
|
||
/// Represents a Matrix room.
|
||
class Room {
|
||
/// The full qualified Matrix ID for the room in the format '!localid:server.abc'.
|
||
final String id;
|
||
|
||
/// Membership status of the user for this room.
|
||
Membership membership;
|
||
|
||
/// The count of unread notifications.
|
||
int notificationCount;
|
||
|
||
/// The count of highlighted notifications.
|
||
int highlightCount;
|
||
|
||
/// A token that can be supplied to the from parameter of the rooms/{roomId}/messages endpoint.
|
||
String prev_batch;
|
||
|
||
/// The users which can be used to generate a room name if the room does not have one.
|
||
/// Required if the room's m.room.name or m.room.canonical_alias state events are unset or empty.
|
||
List<String> mHeroes = [];
|
||
|
||
/// The number of users with membership of join, including the client's own user ID.
|
||
int mJoinedMemberCount;
|
||
|
||
/// The number of users with membership of invite.
|
||
int mInvitedMemberCount;
|
||
|
||
StatesMap states = StatesMap();
|
||
|
||
/// Key-Value store for ephemerals.
|
||
Map<String, RoomAccountData> ephemerals = {};
|
||
|
||
/// Key-Value store for private account data only visible for this user.
|
||
Map<String, RoomAccountData> roomAccountData = {};
|
||
|
||
olm.OutboundGroupSession get outboundGroupSession => _outboundGroupSession;
|
||
olm.OutboundGroupSession _outboundGroupSession;
|
||
|
||
List<String> _outboundGroupSessionDevices;
|
||
|
||
/// Clears the existing outboundGroupSession, tries to create a new one and
|
||
/// stores it as an ingoingGroupSession in the [sessionKeys]. Then sends the
|
||
/// new session encrypted with olm to all non-blocked devices using
|
||
/// to-device-messaging.
|
||
Future<void> createOutboundGroupSession() async {
|
||
await clearOutboundGroupSession(wipe: true);
|
||
List<DeviceKeys> deviceKeys = await getUserDeviceKeys();
|
||
olm.OutboundGroupSession outboundGroupSession;
|
||
List<String> outboundGroupSessionDevices = [];
|
||
for (DeviceKeys keys in deviceKeys) {
|
||
if (!keys.blocked) outboundGroupSessionDevices.add(keys.deviceId);
|
||
}
|
||
outboundGroupSessionDevices.sort();
|
||
try {
|
||
outboundGroupSession = olm.OutboundGroupSession();
|
||
outboundGroupSession.create();
|
||
} catch (e) {
|
||
outboundGroupSession = null;
|
||
print("[LibOlm] Unable to create new outboundGroupSession: " +
|
||
e.toString());
|
||
}
|
||
|
||
if (outboundGroupSession == null) return;
|
||
// Add as an inboundSession to the [sessionKeys].
|
||
Map<String, dynamic> rawSession = {
|
||
"algorithm": "m.megolm.v1.aes-sha2",
|
||
"room_id": this.id,
|
||
"session_id": outboundGroupSession.session_id(),
|
||
"session_key": outboundGroupSession.session_key(),
|
||
};
|
||
setSessionKey(rawSession["session_id"], rawSession);
|
||
try {
|
||
await client.sendToDevice(deviceKeys, "m.room_key", rawSession);
|
||
_outboundGroupSession = outboundGroupSession;
|
||
_outboundGroupSessionDevices = outboundGroupSessionDevices;
|
||
await _storeOutboundGroupSession();
|
||
} catch (e) {
|
||
print(
|
||
"[LibOlm] Unable to send the session key to the participating devices: " +
|
||
e.toString());
|
||
await this.clearOutboundGroupSession();
|
||
}
|
||
return;
|
||
}
|
||
|
||
Future<void> _storeOutboundGroupSession() async {
|
||
if (_outboundGroupSession == null) return;
|
||
await client.storeAPI?.setItem(
|
||
"/clients/${client.deviceID}/rooms/${this.id}/outbound_group_session",
|
||
_outboundGroupSession.pickle(client.userID));
|
||
await client.storeAPI?.setItem(
|
||
"/clients/${client.deviceID}/rooms/${this.id}/outbound_group_session_devices",
|
||
json.encode(_outboundGroupSessionDevices));
|
||
return;
|
||
}
|
||
|
||
/// Clears the existing outboundGroupSession but first checks if the participating
|
||
/// devices have been changed. Returns false if the session has not been cleared because
|
||
/// it wasn't necessary.
|
||
Future<bool> clearOutboundGroupSession({bool wipe = false}) async {
|
||
if (!wipe && this._outboundGroupSessionDevices != null) {
|
||
List<DeviceKeys> deviceKeys = await getUserDeviceKeys();
|
||
List<String> outboundGroupSessionDevices = [];
|
||
for (DeviceKeys keys in deviceKeys) {
|
||
if (!keys.blocked) outboundGroupSessionDevices.add(keys.deviceId);
|
||
}
|
||
outboundGroupSessionDevices.sort();
|
||
if (outboundGroupSessionDevices.toString() ==
|
||
this._outboundGroupSessionDevices.toString()) {
|
||
return false;
|
||
}
|
||
}
|
||
this._outboundGroupSessionDevices == null;
|
||
await client.storeAPI?.setItem(
|
||
"/clients/${client.deviceID}/rooms/${this.id}/outbound_group_session",
|
||
null);
|
||
await client.storeAPI?.setItem(
|
||
"/clients/${client.deviceID}/rooms/${this.id}/outbound_group_session_devices",
|
||
null);
|
||
this._outboundGroupSession?.free();
|
||
this._outboundGroupSession = null;
|
||
return true;
|
||
}
|
||
|
||
/// Key-Value store of session ids to the session keys. Only m.megolm.v1.aes-sha2
|
||
/// session keys are supported. They are stored as a Map with the following keys:
|
||
/// {
|
||
/// "algorithm": "m.megolm.v1.aes-sha2",
|
||
/// "room_id": "!Cuyf34gef24t:localhost",
|
||
/// "session_id": "X3lUlvLELLYxeTx4yOVu6UDpasGEVO0Jbu+QFnm0cKQ",
|
||
/// "session_key": "AgAAAADxKHa9uFxcXzwYoNueL5Xqi69IkD4sni8LlfJL7qNBEY..."
|
||
/// }
|
||
Map<String, SessionKey> get sessionKeys => _sessionKeys;
|
||
Map<String, SessionKey> _sessionKeys = {};
|
||
|
||
/// Add a new session key to the [sessionKeys].
|
||
void setSessionKey(String sessionId, Map<String, dynamic> content,
|
||
{bool forwarded = false}) {
|
||
if (sessionKeys.containsKey(sessionId)) return;
|
||
olm.InboundGroupSession inboundGroupSession;
|
||
if (content["algorithm"] == "m.megolm.v1.aes-sha2") {
|
||
try {
|
||
inboundGroupSession = olm.InboundGroupSession();
|
||
if (forwarded) {
|
||
inboundGroupSession.import_session(content["session_key"]);
|
||
} else {
|
||
inboundGroupSession.create(content["session_key"]);
|
||
}
|
||
} catch (e) {
|
||
inboundGroupSession = null;
|
||
print("[LibOlm] Could not create new InboundGroupSession: " +
|
||
e.toString());
|
||
}
|
||
}
|
||
_sessionKeys[sessionId] = SessionKey(
|
||
content: content,
|
||
inboundGroupSession: inboundGroupSession,
|
||
indexes: {},
|
||
key: client.userID,
|
||
);
|
||
if (_fullyRestored) {
|
||
client.storeAPI?.setItem(
|
||
"/clients/${client.deviceID}/rooms/${this.id}/session_keys",
|
||
json.encode(sessionKeys));
|
||
}
|
||
_tryAgainDecryptLastMessage();
|
||
onSessionKeyReceived.add(sessionId);
|
||
}
|
||
|
||
void _tryAgainDecryptLastMessage() {
|
||
if (getState("m.room.encrypted") != null) {
|
||
final Event decrypted = getState("m.room.encrypted").decrypted;
|
||
if (decrypted.type != EventTypes.Encrypted) {
|
||
setState(decrypted);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Returns the [Event] for the given [typeKey] and optional [stateKey].
|
||
/// If no [stateKey] is provided, it defaults to an empty string.
|
||
Event getState(String typeKey, [String stateKey = ""]) =>
|
||
states.states[typeKey] != null ? states.states[typeKey][stateKey] : null;
|
||
|
||
/// Adds the [state] to this room and overwrites a state with the same
|
||
/// typeKey/stateKey key pair if there is one.
|
||
void setState(Event state) {
|
||
// Decrypt if necessary
|
||
if (state.type == EventTypes.Encrypted) {
|
||
try {
|
||
state = decryptGroupMessage(state);
|
||
} catch (e) {
|
||
print("[LibOlm] Could not decrypt room state: " + e.toString());
|
||
}
|
||
}
|
||
// Check if this is a member change and we need to clear the outboundGroupSession.
|
||
if (encrypted &&
|
||
outboundGroupSession != null &&
|
||
state.type == EventTypes.RoomMember) {
|
||
User newUser = state.asUser;
|
||
User oldUser = getState("m.room.member", newUser.id)?.asUser;
|
||
if (oldUser == null || oldUser.membership != newUser.membership) {
|
||
clearOutboundGroupSession();
|
||
}
|
||
}
|
||
if ((getState(state.typeKey)?.time?.millisecondsSinceEpoch ?? 0) >
|
||
(state.time?.millisecondsSinceEpoch ?? 1)) {
|
||
return;
|
||
}
|
||
if (!states.states.containsKey(state.typeKey)) {
|
||
states.states[state.typeKey] = {};
|
||
}
|
||
states.states[state.typeKey][state.stateKey ?? ""] = state;
|
||
}
|
||
|
||
/// ID of the fully read marker event.
|
||
String get fullyRead => roomAccountData["m.fully_read"] != null
|
||
? roomAccountData["m.fully_read"].content["event_id"]
|
||
: "";
|
||
|
||
/// If something changes, this callback will be triggered. Will return the
|
||
/// room id.
|
||
final StreamController<String> onUpdate = StreamController.broadcast();
|
||
|
||
/// If there is a new session key received, this will be triggered with
|
||
/// the session ID.
|
||
final StreamController<String> onSessionKeyReceived =
|
||
StreamController.broadcast();
|
||
|
||
/// The name of the room if set by a participant.
|
||
String get name => states["m.room.name"] != null
|
||
? states["m.room.name"].content["name"]
|
||
: "";
|
||
|
||
/// The topic of the room if set by a participant.
|
||
String get topic => states["m.room.topic"] != null
|
||
? states["m.room.topic"].content["topic"]
|
||
: "";
|
||
|
||
/// The avatar of the room if set by a participant.
|
||
MxContent get avatar {
|
||
if (states["m.room.avatar"] != null) {
|
||
return MxContent(states["m.room.avatar"].content["url"]);
|
||
}
|
||
if (mHeroes != null && mHeroes.length == 1 && states[mHeroes[0]] != null) {
|
||
return states[mHeroes[0]].asUser.avatarUrl;
|
||
}
|
||
if (membership == Membership.invite &&
|
||
getState("m.room.member", client.userID) != null) {
|
||
return getState("m.room.member", client.userID).sender.avatarUrl;
|
||
}
|
||
return MxContent("");
|
||
}
|
||
|
||
/// The address in the format: #roomname:homeserver.org.
|
||
String get canonicalAlias => states["m.room.canonical_alias"] != null
|
||
? states["m.room.canonical_alias"].content["alias"]
|
||
: "";
|
||
|
||
/// If this room is a direct chat, this is the matrix ID of the user.
|
||
/// Returns null otherwise.
|
||
String get directChatMatrixID {
|
||
String returnUserId;
|
||
if (client.directChats is Map<String, dynamic>) {
|
||
client.directChats.forEach((String userId, dynamic roomIds) {
|
||
if (roomIds is List<dynamic>) {
|
||
for (int i = 0; i < roomIds.length; i++) {
|
||
if (roomIds[i] == this.id) {
|
||
returnUserId = userId;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
return returnUserId;
|
||
}
|
||
|
||
/// Wheither this is a direct chat or not
|
||
bool get isDirectChat => directChatMatrixID != null;
|
||
|
||
/// Must be one of [all, mention]
|
||
String notificationSettings;
|
||
|
||
Event get lastEvent {
|
||
DateTime lastTime = DateTime.fromMillisecondsSinceEpoch(0);
|
||
Event lastEvent = getState("m.room.message");
|
||
if (lastEvent == null) {
|
||
states.forEach((final String key, final entry) {
|
||
if (!entry.containsKey("")) return;
|
||
final Event state = entry[""];
|
||
if (state.time != null &&
|
||
state.time.millisecondsSinceEpoch >
|
||
lastTime.millisecondsSinceEpoch) {
|
||
lastTime = state.time;
|
||
lastEvent = state;
|
||
}
|
||
});
|
||
}
|
||
return lastEvent;
|
||
}
|
||
|
||
/// Returns a list of all current typing users.
|
||
List<User> get typingUsers {
|
||
if (!ephemerals.containsKey("m.typing")) return [];
|
||
List<dynamic> typingMxid = ephemerals["m.typing"].content["user_ids"];
|
||
List<User> typingUsers = [];
|
||
for (int i = 0; i < typingMxid.length; i++) {
|
||
typingUsers.add(getUserByMXIDSync(typingMxid[i]));
|
||
}
|
||
return typingUsers;
|
||
}
|
||
|
||
/// Your current client instance.
|
||
final Client client;
|
||
|
||
Room({
|
||
this.id,
|
||
this.membership = Membership.join,
|
||
this.notificationCount = 0,
|
||
this.highlightCount = 0,
|
||
this.prev_batch = "",
|
||
this.client,
|
||
this.notificationSettings,
|
||
this.mHeroes = const [],
|
||
this.mInvitedMemberCount = 0,
|
||
this.mJoinedMemberCount = 0,
|
||
this.roomAccountData = const {},
|
||
});
|
||
|
||
/// The default count of how much events should be requested when requesting the
|
||
/// history of this room.
|
||
static const int DefaultHistoryCount = 100;
|
||
|
||
/// Calculates the displayname. First checks if there is a name, then checks for a canonical alias and
|
||
/// then generates a name from the heroes.
|
||
String get displayname {
|
||
if (name != null && name.isNotEmpty) return name;
|
||
if (canonicalAlias != null &&
|
||
canonicalAlias.isNotEmpty &&
|
||
canonicalAlias.length > 3) {
|
||
return canonicalAlias.localpart;
|
||
}
|
||
List<String> heroes = [];
|
||
if (mHeroes != null &&
|
||
mHeroes.isNotEmpty &&
|
||
mHeroes.any((h) => h.isNotEmpty)) {
|
||
heroes = mHeroes;
|
||
} else {
|
||
if (states["m.room.member"] is Map<String, dynamic>) {
|
||
for (var entry in states["m.room.member"].entries) {
|
||
Event state = entry.value;
|
||
if (state.type == EventTypes.RoomMember &&
|
||
state.stateKey != client?.userID) heroes.add(state.stateKey);
|
||
}
|
||
}
|
||
}
|
||
if (heroes.isNotEmpty) {
|
||
String displayname = "";
|
||
for (int i = 0; i < heroes.length; i++) {
|
||
if (heroes[i].isEmpty) continue;
|
||
displayname += getUserByMXIDSync(heroes[i]).calcDisplayname() + ", ";
|
||
}
|
||
return displayname.substring(0, displayname.length - 2);
|
||
}
|
||
if (membership == Membership.invite &&
|
||
getState("m.room.member", client.userID) != null) {
|
||
return getState("m.room.member", client.userID).sender.calcDisplayname();
|
||
}
|
||
return "Empty chat";
|
||
}
|
||
|
||
/// The last message sent to this room.
|
||
String get lastMessage {
|
||
if (lastEvent != null) {
|
||
return lastEvent.body;
|
||
} else {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
/// When the last message received.
|
||
DateTime get timeCreated {
|
||
if (lastEvent != null) {
|
||
return lastEvent.time;
|
||
}
|
||
return DateTime.now();
|
||
}
|
||
|
||
/// Call the Matrix API to change the name of this room. Returns the event ID of the
|
||
/// new m.room.name event.
|
||
Future<String> setName(String newName) async {
|
||
final Map<String, dynamic> resp = await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/rooms/${id}/state/m.room.name",
|
||
data: {"name": newName});
|
||
return resp["event_id"];
|
||
}
|
||
|
||
/// Call the Matrix API to change the topic of this room.
|
||
Future<String> setDescription(String newName) async {
|
||
final Map<String, dynamic> resp = await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/rooms/${id}/state/m.room.topic",
|
||
data: {"topic": newName});
|
||
return resp["event_id"];
|
||
}
|
||
|
||
/// Sends a normal text message to this room. Returns the event ID generated
|
||
/// by the server for this message.
|
||
Future<String> sendTextEvent(String message, {String txid, Event inReplyTo}) {
|
||
String type = "m.text";
|
||
if (message.startsWith("/me ")) {
|
||
type = "m.emote";
|
||
message = message.substring(4);
|
||
}
|
||
return sendEvent({"msgtype": type, "body": message},
|
||
txid: txid, inReplyTo: inReplyTo);
|
||
}
|
||
|
||
/// Sends a [file] to this room after uploading it. The [msgType] is optional
|
||
/// and will be detected by the mimetype of the file. Returns the mxc uri of
|
||
/// the uploaded file.
|
||
Future<String> sendFileEvent(MatrixFile file,
|
||
{String msgType = "m.file",
|
||
String txid,
|
||
Event inReplyTo,
|
||
Map<String, dynamic> info}) async {
|
||
String fileName = file.path.split("/").last;
|
||
final bool sendEncrypted = this.encrypted && client.fileEncryptionEnabled;
|
||
EncryptedFile encryptedFile;
|
||
if (sendEncrypted) {
|
||
encryptedFile = await file.encrypt();
|
||
}
|
||
final String uploadResp = await client.upload(file);
|
||
|
||
// Send event
|
||
Map<String, dynamic> content = {
|
||
"msgtype": msgType,
|
||
"body": fileName,
|
||
"filename": fileName,
|
||
if (!sendEncrypted) "url": uploadResp,
|
||
if (sendEncrypted)
|
||
"file": {
|
||
"url": uploadResp,
|
||
"mimetype": mime(file.path),
|
||
"v": "v2",
|
||
"key": {
|
||
"alg": "A256CTR",
|
||
"ext": true,
|
||
"k": encryptedFile.k,
|
||
"key_ops": ["encrypt", "decrypt"],
|
||
"kty": "oct"
|
||
},
|
||
"iv": encryptedFile.iv,
|
||
"hashes": {"sha256": encryptedFile.sha256}
|
||
},
|
||
"info": info != null
|
||
? info
|
||
: {
|
||
"mimetype": mime(file.path),
|
||
"size": file.size,
|
||
}
|
||
};
|
||
unawaited(sendEvent(content, txid: txid, inReplyTo: inReplyTo));
|
||
return uploadResp;
|
||
}
|
||
|
||
/// Sends an audio file to this room and returns the mxc uri.
|
||
Future<String> sendAudioEvent(MatrixFile file,
|
||
{String txid, Event inReplyTo}) async {
|
||
return await sendFileEvent(file,
|
||
msgType: "m.audio", txid: txid, inReplyTo: inReplyTo);
|
||
}
|
||
|
||
/// Sends an image to this room and returns the mxc uri.
|
||
Future<String> sendImageEvent(MatrixFile file,
|
||
{String txid, int width, int height, Event inReplyTo}) async {
|
||
return await sendFileEvent(file,
|
||
msgType: "m.image",
|
||
txid: txid,
|
||
inReplyTo: inReplyTo,
|
||
info: {
|
||
"size": file.size,
|
||
"mimetype": mime(file.path.split("/").last),
|
||
"w": width,
|
||
"h": height,
|
||
});
|
||
}
|
||
|
||
/// Sends an video to this room and returns the mxc uri.
|
||
Future<String> sendVideoEvent(MatrixFile file,
|
||
{String txid,
|
||
int videoWidth,
|
||
int videoHeight,
|
||
int duration,
|
||
MatrixFile thumbnail,
|
||
int thumbnailWidth,
|
||
int thumbnailHeight,
|
||
Event inReplyTo}) async {
|
||
String fileName = file.path.split("/").last;
|
||
Map<String, dynamic> info = {
|
||
"size": file.size,
|
||
"mimetype": mime(fileName),
|
||
};
|
||
if (videoWidth != null) {
|
||
info["w"] = videoWidth;
|
||
}
|
||
if (thumbnailHeight != null) {
|
||
info["h"] = thumbnailHeight;
|
||
}
|
||
if (duration != null) {
|
||
info["duration"] = duration;
|
||
}
|
||
if (thumbnail != null && !(this.encrypted && client.encryptionEnabled)) {
|
||
String thumbnailName = file.path.split("/").last;
|
||
final String thumbnailUploadResp = await client.upload(thumbnail);
|
||
info["thumbnail_url"] = thumbnailUploadResp;
|
||
info["thumbnail_info"] = {
|
||
"size": thumbnail.size,
|
||
"mimetype": mime(thumbnailName),
|
||
};
|
||
if (thumbnailWidth != null) {
|
||
info["thumbnail_info"]["w"] = thumbnailWidth;
|
||
}
|
||
if (thumbnailHeight != null) {
|
||
info["thumbnail_info"]["h"] = thumbnailHeight;
|
||
}
|
||
}
|
||
|
||
return await sendFileEvent(
|
||
file,
|
||
msgType: "m.video",
|
||
txid: txid,
|
||
inReplyTo: inReplyTo,
|
||
info: info,
|
||
);
|
||
}
|
||
|
||
/// Sends an event to this room with this json as a content. Returns the
|
||
/// event ID generated from the server.
|
||
Future<String> sendEvent(Map<String, dynamic> content,
|
||
{String txid, Event inReplyTo}) async {
|
||
final String type = "m.room.message";
|
||
final String sendType = (this.encrypted && client.encryptionEnabled)
|
||
? "m.room.encrypted"
|
||
: type;
|
||
|
||
// Create new transaction id
|
||
String messageID;
|
||
final int now = DateTime.now().millisecondsSinceEpoch;
|
||
if (txid == null) {
|
||
messageID = "msg$now";
|
||
} else {
|
||
messageID = txid;
|
||
}
|
||
|
||
if (inReplyTo != null) {
|
||
String replyText = "<${inReplyTo.senderId}> " + inReplyTo.body;
|
||
List<String> replyTextLines = replyText.split("\n");
|
||
for (int i = 0; i < replyTextLines.length; i++) {
|
||
replyTextLines[i] = "> " + replyTextLines[i];
|
||
}
|
||
replyText = replyTextLines.join("\n");
|
||
content["format"] = "org.matrix.custom.html";
|
||
content["formatted_body"] =
|
||
'<mx-reply><blockquote><a href="https://matrix.to/#/${inReplyTo.room.id}/${inReplyTo.eventId}">In reply to</a> <a href="https://matrix.to/#/${inReplyTo.senderId}">${inReplyTo.senderId}</a><br>${inReplyTo.body}</blockquote></mx-reply>${content["formatted_body"] ?? content["body"]}';
|
||
content["body"] = replyText + "\n\n${content["body"] ?? ""}";
|
||
content["m.relates_to"] = {
|
||
"m.in_reply_to": {
|
||
"event_id": inReplyTo.eventId,
|
||
},
|
||
};
|
||
}
|
||
|
||
// Display a *sending* event and store it.
|
||
EventUpdate eventUpdate =
|
||
EventUpdate(type: "timeline", roomID: id, eventType: type, content: {
|
||
"type": type,
|
||
"event_id": messageID,
|
||
"sender": client.userID,
|
||
"status": 0,
|
||
"origin_server_ts": now,
|
||
"content": content
|
||
});
|
||
client.onEvent.add(eventUpdate);
|
||
await client.store?.transaction(() {
|
||
client.store.storeEventUpdate(eventUpdate);
|
||
return;
|
||
});
|
||
|
||
// Send the text and on success, store and display a *sent* event.
|
||
try {
|
||
final Map<String, dynamic> response = await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/rooms/${id}/send/$sendType/$messageID",
|
||
data: client.encryptionEnabled
|
||
? await encryptGroupMessagePayload(content)
|
||
: content);
|
||
final String res = response["event_id"];
|
||
eventUpdate.content["status"] = 1;
|
||
eventUpdate.content["unsigned"] = {"transaction_id": messageID};
|
||
eventUpdate.content["event_id"] = res;
|
||
client.onEvent.add(eventUpdate);
|
||
await client.store?.transaction(() {
|
||
client.store.storeEventUpdate(eventUpdate);
|
||
return;
|
||
});
|
||
return res;
|
||
} catch (exception) {
|
||
print("[Client] Error while sending: " + exception.toString());
|
||
// On error, set status to -1
|
||
eventUpdate.content["status"] = -1;
|
||
eventUpdate.content["unsigned"] = {"transaction_id": messageID};
|
||
client.onEvent.add(eventUpdate);
|
||
await client.store?.transaction(() {
|
||
client.store.storeEventUpdate(eventUpdate);
|
||
return;
|
||
});
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Call the Matrix API to join this room if the user is not already a member.
|
||
/// If this room is intended to be a direct chat, the direct chat flag will
|
||
/// automatically be set.
|
||
Future<void> join() async {
|
||
try {
|
||
await client.jsonRequest(
|
||
type: HTTPType.POST, action: "/client/r0/rooms/${id}/join");
|
||
final Event invitation = getState("m.room.member", client.userID);
|
||
if (invitation != null &&
|
||
invitation.content["is_direct"] is bool &&
|
||
invitation.content["is_direct"]) {
|
||
await addToDirectChat(invitation.sender.id);
|
||
}
|
||
} on MatrixException catch (exception) {
|
||
if (exception.errorMessage == "No known servers") {
|
||
await client.store?.forgetRoom(id);
|
||
client.onRoomUpdate.add(
|
||
RoomUpdate(
|
||
id: id,
|
||
membership: Membership.leave,
|
||
notification_count: 0,
|
||
highlight_count: 0),
|
||
);
|
||
}
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
/// Call the Matrix API to leave this room. If this room is set as a direct
|
||
/// chat, this will be removed too.
|
||
Future<void> leave() async {
|
||
if (directChatMatrixID != "") await removeFromDirectChat();
|
||
await client.jsonRequest(
|
||
type: HTTPType.POST, action: "/client/r0/rooms/${id}/leave");
|
||
return;
|
||
}
|
||
|
||
/// Call the Matrix API to forget this room if you already left it.
|
||
Future<void> forget() async {
|
||
await client.store?.forgetRoom(id);
|
||
await client.jsonRequest(
|
||
type: HTTPType.POST, action: "/client/r0/rooms/${id}/forget");
|
||
return;
|
||
}
|
||
|
||
/// Call the Matrix API to kick a user from this room.
|
||
Future<void> kick(String userID) async {
|
||
await client.jsonRequest(
|
||
type: HTTPType.POST,
|
||
action: "/client/r0/rooms/${id}/kick",
|
||
data: {"user_id": userID});
|
||
return;
|
||
}
|
||
|
||
/// Call the Matrix API to ban a user from this room.
|
||
Future<void> ban(String userID) async {
|
||
await client.jsonRequest(
|
||
type: HTTPType.POST,
|
||
action: "/client/r0/rooms/${id}/ban",
|
||
data: {"user_id": userID});
|
||
return;
|
||
}
|
||
|
||
/// Call the Matrix API to unban a banned user from this room.
|
||
Future<void> unban(String userID) async {
|
||
await client.jsonRequest(
|
||
type: HTTPType.POST,
|
||
action: "/client/r0/rooms/${id}/unban",
|
||
data: {"user_id": userID});
|
||
return;
|
||
}
|
||
|
||
/// Set the power level of the user with the [userID] to the value [power].
|
||
/// Returns the event ID of the new state event. If there is no known
|
||
/// power level event, there might something broken and this returns null.
|
||
Future<String> setPower(String userID, int power) async {
|
||
if (states["m.room.power_levels"] == null) return null;
|
||
Map<String, dynamic> powerMap = {}
|
||
..addAll(states["m.room.power_levels"].content);
|
||
if (powerMap["users"] == null) powerMap["users"] = {};
|
||
powerMap["users"][userID] = power;
|
||
|
||
final Map<String, dynamic> resp = await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/rooms/$id/state/m.room.power_levels",
|
||
data: powerMap);
|
||
return resp["event_id"];
|
||
}
|
||
|
||
/// Call the Matrix API to invite a user to this room.
|
||
Future<void> invite(String userID) async {
|
||
await client.jsonRequest(
|
||
type: HTTPType.POST,
|
||
action: "/client/r0/rooms/${id}/invite",
|
||
data: {"user_id": userID});
|
||
return;
|
||
}
|
||
|
||
/// Request more previous events from the server. [historyCount] defines how much events should
|
||
/// be received maximum. When the request is answered, [onHistoryReceived] will be triggered **before**
|
||
/// the historical events will be published in the onEvent stream.
|
||
Future<void> requestHistory(
|
||
{int historyCount = DefaultHistoryCount, onHistoryReceived}) async {
|
||
final dynamic resp = await client.jsonRequest(
|
||
type: HTTPType.GET,
|
||
action:
|
||
"/client/r0/rooms/$id/messages?from=${prev_batch}&dir=b&limit=$historyCount&filter=${Client.syncFilters}");
|
||
|
||
if (onHistoryReceived != null) onHistoryReceived();
|
||
prev_batch = resp["end"];
|
||
await client.store?.storeRoomPrevBatch(this);
|
||
|
||
if (!(resp["chunk"] is List<dynamic> &&
|
||
resp["chunk"].length > 0 &&
|
||
resp["end"] is String)) return;
|
||
|
||
if (resp["state"] is List<dynamic>) {
|
||
await client.store?.transaction(() {
|
||
for (int i = 0; i < resp["state"].length; i++) {
|
||
EventUpdate eventUpdate = EventUpdate(
|
||
type: "state",
|
||
roomID: id,
|
||
eventType: resp["state"][i]["type"],
|
||
content: resp["state"][i],
|
||
).decrypt(this);
|
||
client.onEvent.add(eventUpdate);
|
||
client.store.storeEventUpdate(eventUpdate);
|
||
}
|
||
return;
|
||
});
|
||
if (client.store == null) {
|
||
for (int i = 0; i < resp["state"].length; i++) {
|
||
EventUpdate eventUpdate = EventUpdate(
|
||
type: "state",
|
||
roomID: id,
|
||
eventType: resp["state"][i]["type"],
|
||
content: resp["state"][i],
|
||
).decrypt(this);
|
||
client.onEvent.add(eventUpdate);
|
||
}
|
||
}
|
||
}
|
||
|
||
List<dynamic> history = resp["chunk"];
|
||
await client.store?.transaction(() {
|
||
for (int i = 0; i < history.length; i++) {
|
||
EventUpdate eventUpdate = EventUpdate(
|
||
type: "history",
|
||
roomID: id,
|
||
eventType: history[i]["type"],
|
||
content: history[i],
|
||
).decrypt(this);
|
||
client.onEvent.add(eventUpdate);
|
||
client.store.storeEventUpdate(eventUpdate);
|
||
client.store.setRoomPrevBatch(id, resp["end"]);
|
||
}
|
||
return;
|
||
});
|
||
if (client.store == null) {
|
||
for (int i = 0; i < history.length; i++) {
|
||
EventUpdate eventUpdate = EventUpdate(
|
||
type: "history",
|
||
roomID: id,
|
||
eventType: history[i]["type"],
|
||
content: history[i],
|
||
).decrypt(this);
|
||
client.onEvent.add(eventUpdate);
|
||
}
|
||
}
|
||
client.onRoomUpdate.add(
|
||
RoomUpdate(
|
||
id: id,
|
||
membership: membership,
|
||
prev_batch: resp["end"],
|
||
notification_count: notificationCount,
|
||
highlight_count: highlightCount,
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Sets this room as a direct chat for this user if not already.
|
||
Future<void> addToDirectChat(String userID) async {
|
||
Map<String, dynamic> directChats = client.directChats;
|
||
if (directChats.containsKey(userID)) {
|
||
if (!directChats[userID].contains(id)) {
|
||
directChats[userID].add(id);
|
||
} else {
|
||
return;
|
||
} // Is already in direct chats
|
||
} else {
|
||
directChats[userID] = [id];
|
||
}
|
||
|
||
await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/user/${client.userID}/account_data/m.direct",
|
||
data: directChats);
|
||
return;
|
||
}
|
||
|
||
/// Removes this room from all direct chat tags.
|
||
Future<void> removeFromDirectChat() async {
|
||
Map<String, dynamic> directChats = client.directChats;
|
||
if (directChats.containsKey(directChatMatrixID) &&
|
||
directChats[directChatMatrixID].contains(id)) {
|
||
directChats[directChatMatrixID].remove(id);
|
||
} else {
|
||
return;
|
||
} // Nothing to do here
|
||
|
||
await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/user/${client.userID}/account_data/m.direct",
|
||
data: directChats);
|
||
return;
|
||
}
|
||
|
||
/// Sends *m.fully_read* and *m.read* for the given event ID.
|
||
Future<void> sendReadReceipt(String eventID) async {
|
||
this.notificationCount = 0;
|
||
await client?.store?.resetNotificationCount(this.id);
|
||
await client.jsonRequest(
|
||
type: HTTPType.POST,
|
||
action: "/client/r0/rooms/$id/read_markers",
|
||
data: {
|
||
"m.fully_read": eventID,
|
||
"m.read": eventID,
|
||
});
|
||
return;
|
||
}
|
||
|
||
void restoreGroupSessionKeys() async {
|
||
// Restore the inbound and outbound session keys
|
||
if (client.encryptionEnabled && client.storeAPI != null) {
|
||
final String outboundGroupSessionPickle = await client.storeAPI.getItem(
|
||
"/clients/${client.deviceID}/rooms/${this.id}/outbound_group_session");
|
||
if (outboundGroupSessionPickle != null) {
|
||
try {
|
||
this._outboundGroupSession = olm.OutboundGroupSession();
|
||
this
|
||
._outboundGroupSession
|
||
.unpickle(client.userID, outboundGroupSessionPickle);
|
||
} catch (e) {
|
||
this._outboundGroupSession = null;
|
||
print("[LibOlm] Unable to unpickle outboundGroupSession: " +
|
||
e.toString());
|
||
}
|
||
}
|
||
final String outboundGroupSessionDevicesString = await client.storeAPI
|
||
.getItem(
|
||
"/clients/${client.deviceID}/rooms/${this.id}/outbound_group_session_devices");
|
||
if (outboundGroupSessionDevicesString != null) {
|
||
this._outboundGroupSessionDevices =
|
||
List<String>.from(json.decode(outboundGroupSessionDevicesString));
|
||
}
|
||
final String sessionKeysPickle = await client.storeAPI
|
||
.getItem("/clients/${client.deviceID}/rooms/${this.id}/session_keys");
|
||
if (sessionKeysPickle?.isNotEmpty ?? false) {
|
||
final Map<String, dynamic> map = json.decode(sessionKeysPickle);
|
||
if (this._sessionKeys == null) this._sessionKeys = {};
|
||
for (var entry in map.entries) {
|
||
try {
|
||
this._sessionKeys[entry.key] =
|
||
SessionKey.fromJson(entry.value, client.userID);
|
||
} catch (e) {
|
||
print("[LibOlm] Could not unpickle inboundGroupSession: " +
|
||
e.toString());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
await client.storeAPI?.setItem(
|
||
"/clients/${client.deviceID}/rooms/${this.id}/session_keys",
|
||
json.encode(sessionKeys));
|
||
_tryAgainDecryptLastMessage();
|
||
_fullyRestored = true;
|
||
}
|
||
|
||
bool _fullyRestored = false;
|
||
|
||
/// Returns a Room from a json String which comes normally from the store. If the
|
||
/// state are also given, the method will await them.
|
||
static Future<Room> getRoomFromTableRow(
|
||
Map<String, dynamic> row, Client matrix,
|
||
{Future<List<Map<String, dynamic>>> states,
|
||
Future<List<Map<String, dynamic>>> roomAccountData}) async {
|
||
Room newRoom = Room(
|
||
id: row["room_id"],
|
||
membership: Membership.values
|
||
.firstWhere((e) => e.toString() == 'Membership.' + row["membership"]),
|
||
notificationCount: row["notification_count"],
|
||
highlightCount: row["highlight_count"],
|
||
notificationSettings: row["notification_settings"],
|
||
prev_batch: row["prev_batch"],
|
||
mInvitedMemberCount: row["invited_member_count"],
|
||
mJoinedMemberCount: row["joined_member_count"],
|
||
mHeroes: row["heroes"]?.split(",") ?? [],
|
||
client: matrix,
|
||
roomAccountData: {},
|
||
);
|
||
|
||
// Restore the inbound and outbound session keys
|
||
await newRoom.restoreGroupSessionKeys();
|
||
|
||
if (states != null) {
|
||
List<Map<String, dynamic>> rawStates = await states;
|
||
for (int i = 0; i < rawStates.length; i++) {
|
||
Event newState = Event.fromJson(rawStates[i], newRoom);
|
||
newRoom.setState(newState);
|
||
}
|
||
}
|
||
|
||
Map<String, RoomAccountData> newRoomAccountData = {};
|
||
if (roomAccountData != null) {
|
||
List<Map<String, dynamic>> rawRoomAccountData = await roomAccountData;
|
||
for (int i = 0; i < rawRoomAccountData.length; i++) {
|
||
RoomAccountData newData =
|
||
RoomAccountData.fromJson(rawRoomAccountData[i], newRoom);
|
||
newRoomAccountData[newData.typeKey] = newData;
|
||
}
|
||
newRoom.roomAccountData = newRoomAccountData;
|
||
}
|
||
|
||
return newRoom;
|
||
}
|
||
|
||
/// Creates a timeline from the store. Returns a [Timeline] object.
|
||
Future<Timeline> getTimeline(
|
||
{onTimelineUpdateCallback onUpdate,
|
||
onTimelineInsertCallback onInsert}) async {
|
||
List<Event> events =
|
||
client.store != null ? await client.store.getEventList(this) : [];
|
||
|
||
// Try again to decrypt encrypted events and update the database.
|
||
if (this.encrypted && client.store != null) {
|
||
await client.store.transaction(() {
|
||
for (int i = 0; i < events.length; i++) {
|
||
if (events[i].type == EventTypes.Encrypted &&
|
||
events[i].content["body"] == DecryptError.UNKNOWN_SESSION) {
|
||
events[i] = events[i].decrypted;
|
||
if (events[i].type != EventTypes.Encrypted) {
|
||
client.store.storeEventUpdate(
|
||
EventUpdate(
|
||
eventType: events[i].typeKey,
|
||
content: events[i].toJson(),
|
||
roomID: events[i].roomId,
|
||
type: "timeline",
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
Timeline timeline = Timeline(
|
||
room: this,
|
||
events: events,
|
||
onUpdate: onUpdate,
|
||
onInsert: onInsert,
|
||
);
|
||
if (client.store == null) {
|
||
prev_batch = "";
|
||
await requestHistory(historyCount: 10);
|
||
}
|
||
return timeline;
|
||
}
|
||
|
||
/// Returns all participants for this room. With lazy loading this
|
||
/// list may not be complete. User [requestParticipants] in this
|
||
/// case.
|
||
List<User> getParticipants() {
|
||
List<User> userList = [];
|
||
if (states["m.room.member"] is Map<String, dynamic>) {
|
||
for (var entry in states["m.room.member"].entries) {
|
||
Event state = entry.value;
|
||
if (state.type == EventTypes.RoomMember) userList.add(state.asUser);
|
||
}
|
||
}
|
||
return userList;
|
||
}
|
||
|
||
/// 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(
|
||
type: HTTPType.GET, action: "/client/r0/rooms/${id}/members");
|
||
|
||
for (num i = 0; i < res["chunk"].length; i++) {
|
||
User newUser = Event.fromJson(res["chunk"][i], this).asUser;
|
||
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 {
|
||
if (states[mxID] != null) return states[mxID].asUser;
|
||
return requestUser(mxID);
|
||
}
|
||
|
||
/// Returns the [User] object for the given [mxID] or requests it from
|
||
/// the homeserver and returns a default [User] object while waiting.
|
||
User getUserByMXIDSync(String mxID) {
|
||
if (states[mxID] != null) {
|
||
return states[mxID].asUser;
|
||
} else {
|
||
try {
|
||
requestUser(mxID);
|
||
} catch (_) {}
|
||
return User(mxID, room: this);
|
||
}
|
||
}
|
||
|
||
Set<String> _requestingMatrixIds = Set();
|
||
|
||
/// Requests a missing [User] for this room. Important for clients using
|
||
/// lazy loading.
|
||
Future<User> requestUser(String mxID) async {
|
||
if (mxID == null || !_requestingMatrixIds.add(mxID)) return null;
|
||
Map<String, dynamic> resp;
|
||
try {
|
||
resp = await client.jsonRequest(
|
||
type: HTTPType.GET,
|
||
action: "/client/r0/rooms/$id/state/m.room.member/$mxID");
|
||
} catch (exception) {
|
||
_requestingMatrixIds.remove(mxID);
|
||
rethrow;
|
||
}
|
||
final User user = User(mxID,
|
||
displayName: resp["displayname"],
|
||
avatarUrl: resp["avatar_url"],
|
||
room: this);
|
||
states[mxID] = user;
|
||
if (client.store != null) {
|
||
await client.store.transaction(() {
|
||
client.store.storeEventUpdate(
|
||
EventUpdate(
|
||
content: resp,
|
||
roomID: id,
|
||
type: "state",
|
||
eventType: "m.room.member"),
|
||
);
|
||
return;
|
||
});
|
||
}
|
||
if (onUpdate != null) onUpdate.add(id);
|
||
_requestingMatrixIds.remove(mxID);
|
||
return user;
|
||
}
|
||
|
||
/// Searches for the event on the server. Returns null if not found.
|
||
Future<Event> getEventById(String eventID) async {
|
||
final dynamic resp = await client.jsonRequest(
|
||
type: HTTPType.GET, action: "/client/r0/rooms/$id/event/$eventID");
|
||
return Event.fromJson(resp, this);
|
||
}
|
||
|
||
/// Returns the power level of the given user ID.
|
||
int getPowerLevelByUserId(String userId) {
|
||
int powerLevel = 0;
|
||
Event powerLevelState = states["m.room.power_levels"];
|
||
if (powerLevelState == null) return powerLevel;
|
||
if (powerLevelState.content["users_default"] is int) {
|
||
powerLevel = powerLevelState.content["users_default"];
|
||
}
|
||
if (powerLevelState.content["users"] is Map<String, dynamic> &&
|
||
powerLevelState.content["users"][userId] != null) {
|
||
powerLevel = powerLevelState.content["users"][userId];
|
||
}
|
||
return powerLevel;
|
||
}
|
||
|
||
/// Returns the user's own power level.
|
||
int get ownPowerLevel => getPowerLevelByUserId(client.userID);
|
||
|
||
/// Returns the power levels from all users for this room or null if not given.
|
||
Map<String, int> get powerLevels {
|
||
Event powerLevelState = states["m.room.power_levels"];
|
||
if (powerLevelState.content["users"] is Map<String, int>) {
|
||
return powerLevelState.content["users"];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Uploads a new user avatar for this room. Returns the event ID of the new
|
||
/// m.room.avatar event.
|
||
Future<String> setAvatar(MatrixFile file) async {
|
||
final String uploadResp = await client.upload(file);
|
||
final Map<String, dynamic> setAvatarResp = await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/rooms/$id/state/m.room.avatar/",
|
||
data: {"url": uploadResp});
|
||
return setAvatarResp["event_id"];
|
||
}
|
||
|
||
bool _hasPermissionFor(String action) {
|
||
if (getState("m.room.power_levels") == null ||
|
||
getState("m.room.power_levels").content[action] == null) return true;
|
||
return ownPowerLevel >= getState("m.room.power_levels").content[action];
|
||
}
|
||
|
||
/// The level required to ban a user.
|
||
bool get canBan => _hasPermissionFor("ban");
|
||
|
||
/// The default level required to send message events. Can be overridden by the events key.
|
||
bool get canSendDefaultMessages => _hasPermissionFor("events_default");
|
||
|
||
/// The level required to invite a user.
|
||
bool get canInvite => _hasPermissionFor("invite");
|
||
|
||
/// The level required to kick a user.
|
||
bool get canKick => _hasPermissionFor("kick");
|
||
|
||
/// The level required to redact an event.
|
||
bool get canRedact => _hasPermissionFor("redact");
|
||
|
||
/// The default level required to send state events. Can be overridden by the events key.
|
||
bool get canSendDefaultStates => _hasPermissionFor("state_default");
|
||
|
||
bool get canChangePowerLevel => canSendEvent("m.room.power_levels");
|
||
|
||
bool canSendEvent(String eventType) {
|
||
if (getState("m.room.power_levels") == null) return true;
|
||
if (getState("m.room.power_levels").content["events"] == null ||
|
||
getState("m.room.power_levels").content["events"][eventType] == null) {
|
||
return eventType == "m.room.message"
|
||
? canSendDefaultMessages
|
||
: canSendDefaultStates;
|
||
}
|
||
return ownPowerLevel >=
|
||
getState("m.room.power_levels").content["events"][eventType];
|
||
}
|
||
|
||
/// Returns the [PushRuleState] for this room, based on the m.push_rules stored in
|
||
/// the account_data.
|
||
PushRuleState get pushRuleState {
|
||
if (!client.accountData.containsKey("m.push_rules") ||
|
||
!(client.accountData["m.push_rules"].content["global"] is Map)) {
|
||
return PushRuleState.notify;
|
||
}
|
||
final Map<String, dynamic> globalPushRules =
|
||
client.accountData["m.push_rules"].content["global"];
|
||
if (globalPushRules == null) return PushRuleState.notify;
|
||
|
||
if (globalPushRules["override"] is List) {
|
||
for (var i = 0; i < globalPushRules["override"].length; i++) {
|
||
if (globalPushRules["override"][i]["rule_id"] == id) {
|
||
if (globalPushRules["override"][i]["actions"]
|
||
.indexOf("dont_notify") !=
|
||
-1) {
|
||
return PushRuleState.dont_notify;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (globalPushRules["room"] is List) {
|
||
for (var i = 0; i < globalPushRules["room"].length; i++) {
|
||
if (globalPushRules["room"][i]["rule_id"] == id) {
|
||
if (globalPushRules["room"][i]["actions"].indexOf("dont_notify") !=
|
||
-1) {
|
||
return PushRuleState.mentions_only;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
return PushRuleState.notify;
|
||
}
|
||
|
||
/// Sends a request to the homeserver to set the [PushRuleState] for this room.
|
||
/// Returns ErrorResponse if something goes wrong.
|
||
Future<dynamic> setPushRuleState(PushRuleState newState) async {
|
||
if (newState == pushRuleState) return null;
|
||
dynamic resp;
|
||
switch (newState) {
|
||
// All push notifications should be sent to the user
|
||
case PushRuleState.notify:
|
||
if (pushRuleState == PushRuleState.dont_notify) {
|
||
resp = await client.jsonRequest(
|
||
type: HTTPType.DELETE,
|
||
action: "/client/r0/pushrules/global/override/$id",
|
||
data: {});
|
||
} else if (pushRuleState == PushRuleState.mentions_only) {
|
||
resp = await client.jsonRequest(
|
||
type: HTTPType.DELETE,
|
||
action: "/client/r0/pushrules/global/room/$id",
|
||
data: {});
|
||
}
|
||
break;
|
||
// Only when someone mentions the user, a push notification should be sent
|
||
case PushRuleState.mentions_only:
|
||
if (pushRuleState == PushRuleState.dont_notify) {
|
||
resp = await client.jsonRequest(
|
||
type: HTTPType.DELETE,
|
||
action: "/client/r0/pushrules/global/override/$id",
|
||
data: {});
|
||
resp = await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/pushrules/global/room/$id",
|
||
data: {
|
||
"actions": ["dont_notify"]
|
||
});
|
||
} else if (pushRuleState == PushRuleState.notify) {
|
||
resp = await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/pushrules/global/room/$id",
|
||
data: {
|
||
"actions": ["dont_notify"]
|
||
});
|
||
}
|
||
break;
|
||
// No push notification should be ever sent for this room.
|
||
case PushRuleState.dont_notify:
|
||
if (pushRuleState == PushRuleState.mentions_only) {
|
||
resp = await client.jsonRequest(
|
||
type: HTTPType.DELETE,
|
||
action: "/client/r0/pushrules/global/room/$id",
|
||
data: {});
|
||
}
|
||
resp = await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/pushrules/global/override/$id",
|
||
data: {
|
||
"actions": ["dont_notify"],
|
||
"conditions": [
|
||
{"key": "room_id", "kind": "event_match", "pattern": id}
|
||
]
|
||
});
|
||
}
|
||
return resp;
|
||
}
|
||
|
||
/// Redacts this event. Returns [ErrorResponse] on error.
|
||
Future<dynamic> redactEvent(String eventId,
|
||
{String reason, String txid}) async {
|
||
// Create new transaction id
|
||
String messageID;
|
||
final int now = DateTime.now().millisecondsSinceEpoch;
|
||
if (txid == null) {
|
||
messageID = "msg$now";
|
||
} else {
|
||
messageID = txid;
|
||
}
|
||
Map<String, dynamic> data = {};
|
||
if (reason != null) data["reason"] = reason;
|
||
final dynamic resp = await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/rooms/$id/redact/$eventId/$messageID",
|
||
data: data);
|
||
return resp;
|
||
}
|
||
|
||
Future<dynamic> sendTypingInfo(bool isTyping, {int timeout}) {
|
||
Map<String, dynamic> data = {
|
||
"typing": isTyping,
|
||
};
|
||
if (timeout != null) data["timeout"] = timeout;
|
||
return client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/rooms/${this.id}/typing/${client.userID}",
|
||
data: data,
|
||
);
|
||
}
|
||
|
||
/// This is sent by the caller when they wish to establish a call.
|
||
/// [callId] is a unique identifier for the call.
|
||
/// [version] is the version of the VoIP specification this message adheres to. This specification is version 0.
|
||
/// [lifetime] is the time in milliseconds that the invite is valid for. Once the invite age exceeds this value,
|
||
/// clients should discard it. They should also no longer show the call as awaiting an answer in the UI.
|
||
/// [type] The type of session description. Must be 'offer'.
|
||
/// [sdp] The SDP text of the session description.
|
||
Future<String> inviteToCall(String callId, int lifetime, String sdp,
|
||
{String type = "offer", int version = 0, String txid}) async {
|
||
if (txid == null) txid = "txid${DateTime.now().millisecondsSinceEpoch}";
|
||
final Map<String, dynamic> response = await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/rooms/$id/send/m.call.invite/$txid",
|
||
data: {
|
||
"call_id": callId,
|
||
"lifetime": lifetime,
|
||
"offer": {"sdp": sdp, "type": type},
|
||
"version": version,
|
||
},
|
||
);
|
||
return response["event_id"];
|
||
}
|
||
|
||
/// This is sent by callers after sending an invite and by the callee after answering.
|
||
/// Its purpose is to give the other party additional ICE candidates to try using to communicate.
|
||
///
|
||
/// [callId] The ID of the call this event relates to.
|
||
///
|
||
/// [version] The version of the VoIP specification this messages adheres to. This specification is version 0.
|
||
///
|
||
/// [candidates] Array of objects describing the candidates. Example:
|
||
///
|
||
/// ```
|
||
/// [
|
||
/// {
|
||
/// "candidate": "candidate:863018703 1 udp 2122260223 10.9.64.156 43670 typ host generation 0",
|
||
/// "sdpMLineIndex": 0,
|
||
/// "sdpMid": "audio"
|
||
/// }
|
||
/// ],
|
||
/// ```
|
||
Future<String> sendCallCandidates(
|
||
String callId,
|
||
List<Map<String, dynamic>> candidates, {
|
||
int version = 0,
|
||
String txid,
|
||
}) async {
|
||
if (txid == null) txid = "txid${DateTime.now().millisecondsSinceEpoch}";
|
||
final Map<String, dynamic> response = await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/rooms/$id/send/m.call.candidates/$txid",
|
||
data: {
|
||
"call_id": callId,
|
||
"candidates": candidates,
|
||
"version": version,
|
||
},
|
||
);
|
||
return response["event_id"];
|
||
}
|
||
|
||
/// This event is sent by the callee when they wish to answer the call.
|
||
/// [callId] is a unique identifier for the call.
|
||
/// [version] is the version of the VoIP specification this message adheres to. This specification is version 0.
|
||
/// [type] The type of session description. Must be 'answer'.
|
||
/// [sdp] The SDP text of the session description.
|
||
Future<String> answerCall(String callId, String sdp,
|
||
{String type = "answer", int version = 0, String txid}) async {
|
||
if (txid == null) txid = "txid${DateTime.now().millisecondsSinceEpoch}";
|
||
final Map<String, dynamic> response = await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/rooms/$id/send/m.call.answer/$txid",
|
||
data: {
|
||
"call_id": callId,
|
||
"answer": {"sdp": sdp, "type": type},
|
||
"version": version,
|
||
},
|
||
);
|
||
return response["event_id"];
|
||
}
|
||
|
||
/// This event is sent by the callee when they wish to answer the call.
|
||
/// [callId] The ID of the call this event relates to.
|
||
/// [version] is the version of the VoIP specification this message adheres to. This specification is version 0.
|
||
Future<String> hangupCall(String callId,
|
||
{int version = 0, String txid}) async {
|
||
if (txid == null) txid = "txid${DateTime.now().millisecondsSinceEpoch}";
|
||
final Map<String, dynamic> response = await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/rooms/$id/send/m.call.hangup/$txid",
|
||
data: {
|
||
"call_id": callId,
|
||
"version": version,
|
||
},
|
||
);
|
||
return response["event_id"];
|
||
}
|
||
|
||
/// Returns all aliases for this room.
|
||
List<String> get aliases {
|
||
List<String> aliases = [];
|
||
for (Event aliasEvent in states.states["m.room.aliases"].values) {
|
||
if (aliasEvent.content["aliases"] is List) {
|
||
aliases.addAll(aliasEvent.content["aliases"]);
|
||
}
|
||
}
|
||
return aliases;
|
||
}
|
||
|
||
/// A room may be public meaning anyone can join the room without any prior action. Alternatively,
|
||
/// it can be invite meaning that a user who wishes to join the room must first receive an invite
|
||
/// to the room from someone already inside of the room. Currently, knock and private are reserved
|
||
/// keywords which are not implemented.
|
||
JoinRules get joinRules => getState("m.room.join_rules") != null
|
||
? JoinRules.values.firstWhere(
|
||
(r) =>
|
||
r.toString().replaceAll("JoinRules.", "") ==
|
||
getState("m.room.join_rules").content["join_rule"],
|
||
orElse: () => null)
|
||
: null;
|
||
|
||
/// Changes the join rules. You should check first if the user is able to change it.
|
||
Future<void> setJoinRules(JoinRules joinRules) async {
|
||
await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/rooms/$id/state/m.room.join_rules/",
|
||
data: {
|
||
"join_rule": joinRules.toString().replaceAll("JoinRules.", ""),
|
||
},
|
||
);
|
||
return;
|
||
}
|
||
|
||
/// Whether the user has the permission to change the join rules.
|
||
bool get canChangeJoinRules => canSendEvent("m.room.join_rules");
|
||
|
||
/// This event controls whether guest users are allowed to join rooms. If this event
|
||
/// is absent, servers should act as if it is present and has the guest_access value "forbidden".
|
||
GuestAccess get guestAccess => getState("m.room.guest_access") != null
|
||
? GuestAccess.values.firstWhere(
|
||
(r) =>
|
||
r.toString().replaceAll("GuestAccess.", "") ==
|
||
getState("m.room.guest_access").content["guest_access"],
|
||
orElse: () => GuestAccess.forbidden)
|
||
: GuestAccess.forbidden;
|
||
|
||
/// Changes the guest access. You should check first if the user is able to change it.
|
||
Future<void> setGuestAccess(GuestAccess guestAccess) async {
|
||
await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/rooms/$id/state/m.room.guest_access/",
|
||
data: {
|
||
"guest_access": guestAccess.toString().replaceAll("GuestAccess.", ""),
|
||
},
|
||
);
|
||
return;
|
||
}
|
||
|
||
/// Whether the user has the permission to change the guest access.
|
||
bool get canChangeGuestAccess => canSendEvent("m.room.guest_access");
|
||
|
||
/// This event controls whether a user can see the events that happened in a room from before they joined.
|
||
HistoryVisibility get historyVisibility =>
|
||
getState("m.room.history_visibility") != null
|
||
? HistoryVisibility.values.firstWhere(
|
||
(r) =>
|
||
r.toString().replaceAll("HistoryVisibility.", "") ==
|
||
getState("m.room.history_visibility")
|
||
.content["history_visibility"],
|
||
orElse: () => null)
|
||
: null;
|
||
|
||
/// Changes the history visibility. You should check first if the user is able to change it.
|
||
Future<void> setHistoryVisibility(HistoryVisibility historyVisibility) async {
|
||
await client.jsonRequest(
|
||
type: HTTPType.PUT,
|
||
action: "/client/r0/rooms/$id/state/m.room.history_visibility/",
|
||
data: {
|
||
"history_visibility":
|
||
historyVisibility.toString().replaceAll("HistoryVisibility.", ""),
|
||
},
|
||
);
|
||
return;
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
|
||
/// Returns all known device keys for all participants in this room.
|
||
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;
|
||
}
|
||
|
||
/// Encrypts the given json payload and creates a send-ready m.room.encrypted
|
||
/// payload. This will create a new outgoingGroupSession if necessary.
|
||
Future<Map<String, dynamic>> encryptGroupMessagePayload(
|
||
Map<String, dynamic> payload,
|
||
{String type = "m.room.message"}) async {
|
||
if (!this.encrypted || !client.encryptionEnabled) return payload;
|
||
if (this.encryptionAlgorithm != "m.megolm.v1.aes-sha2") {
|
||
throw ("Unknown encryption algorithm");
|
||
}
|
||
if (_outboundGroupSession == null) {
|
||
await createOutboundGroupSession();
|
||
}
|
||
final Map<String, dynamic> payloadContent = {
|
||
"content": payload,
|
||
"type": type,
|
||
"room_id": id,
|
||
};
|
||
Map<String, dynamic> encryptedPayload = {
|
||
"algorithm": "m.megolm.v1.aes-sha2",
|
||
"ciphertext": _outboundGroupSession.encrypt(json.encode(payloadContent)),
|
||
"device_id": client.deviceID,
|
||
"sender_key": client.identityKey,
|
||
"session_id": _outboundGroupSession.session_id(),
|
||
};
|
||
await _storeOutboundGroupSession();
|
||
return encryptedPayload;
|
||
}
|
||
|
||
/// Decrypts the given [event] with one of the available ingoingGroupSessions.
|
||
/// Returns a m.bad.encrypted event if it fails and does nothing if the event
|
||
/// was not encrypted.
|
||
Event decryptGroupMessage(Event event) {
|
||
if (event.type != EventTypes.Encrypted) return event;
|
||
Map<String, dynamic> decryptedPayload;
|
||
try {
|
||
if (!client.encryptionEnabled) {
|
||
throw (DecryptError.NOT_ENABLED);
|
||
}
|
||
if (event.content["algorithm"] != "m.megolm.v1.aes-sha2") {
|
||
throw (DecryptError.UNKNOWN_ALGORITHM);
|
||
}
|
||
final String sessionId = event.content["session_id"];
|
||
if (!sessionKeys.containsKey(sessionId)) {
|
||
throw (DecryptError.UNKNOWN_SESSION);
|
||
}
|
||
final olm.DecryptResult decryptResult = sessionKeys[sessionId]
|
||
.inboundGroupSession
|
||
.decrypt(event.content["ciphertext"]);
|
||
final String messageIndexKey =
|
||
event.eventId + event.time.millisecondsSinceEpoch.toString();
|
||
if (sessionKeys[sessionId].indexes.containsKey(messageIndexKey) &&
|
||
sessionKeys[sessionId].indexes[messageIndexKey] !=
|
||
decryptResult.message_index) {
|
||
if ((_outboundGroupSession?.session_id() ?? "") == sessionId) {
|
||
clearOutboundGroupSession();
|
||
}
|
||
throw (DecryptError.CHANNEL_CORRUPTED);
|
||
}
|
||
sessionKeys[sessionId].indexes[messageIndexKey] =
|
||
decryptResult.message_index;
|
||
_storeOutboundGroupSession();
|
||
// TODO: The client should check that the sender's fingerprint key matches the keys.ed25519 property of the event which established the Megolm session when marking the event as verified.
|
||
|
||
decryptedPayload = json.decode(decryptResult.plaintext);
|
||
} catch (exception) {
|
||
if (exception.toString() == DecryptError.UNKNOWN_SESSION) {
|
||
decryptedPayload = {
|
||
"content": event.content,
|
||
"type": "m.room.encrypted",
|
||
};
|
||
decryptedPayload["content"]["body"] = exception.toString();
|
||
decryptedPayload["content"]["msgtype"] = "m.bad.encrypted";
|
||
} else {
|
||
decryptedPayload = {
|
||
"content": {
|
||
"msgtype": "m.bad.encrypted",
|
||
"body": exception.toString(),
|
||
},
|
||
"type": "m.room.encrypted",
|
||
};
|
||
}
|
||
}
|
||
return Event(
|
||
content: decryptedPayload["content"],
|
||
typeKey: decryptedPayload["type"],
|
||
senderId: event.senderId,
|
||
eventId: event.eventId,
|
||
roomId: event.roomId,
|
||
room: event.room,
|
||
time: event.time,
|
||
unsigned: event.unsigned,
|
||
stateKey: event.stateKey,
|
||
prevContent: event.prevContent,
|
||
status: event.status,
|
||
);
|
||
}
|
||
}
|
||
|
||
abstract class DecryptError {
|
||
static const String NOT_ENABLED = "Encryption is not enabled in your client.";
|
||
static const String UNKNOWN_ALGORITHM = "Unknown encryption algorithm.";
|
||
static const String UNKNOWN_SESSION =
|
||
"The sender has not sent us the session key.";
|
||
static const String CHANNEL_CORRUPTED =
|
||
"The secure channel with the sender was corrupted.";
|
||
}
|