/*
* Famedly Matrix SDK
* Copyright (C) 2019, 2020, 2021 Famedly GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
import 'dart:async';
import 'dart:convert';
import 'dart:core';
import 'dart:typed_data';
import 'package:famedlysdk/src/utils/run_in_root.dart';
import 'package:http/http.dart' as http;
import 'package:olm/olm.dart' as olm;
import 'package:pedantic/pedantic.dart';
import '../encryption.dart';
import '../famedlysdk.dart';
import 'database/database.dart' show Database;
import 'event.dart';
import 'room.dart';
import 'user.dart';
import 'utils/commands_extension.dart';
import 'utils/device_keys_list.dart';
import 'utils/event_update.dart';
import 'utils/matrix_file.dart';
import 'utils/room_update.dart';
import 'utils/to_device_event.dart';
import 'utils/uia_request.dart';
typedef RoomSorter = int Function(Room a, Room b);
enum LoginState { logged, loggedOut }
/// Represents a Matrix client to communicate with a
/// [Matrix](https://matrix.org) homeserver and is the entry point for this
/// SDK.
class Client extends MatrixApi {
int _id;
// Keeps track of the currently ongoing syncRequest
// in case we want to cancel it.
int _currentSyncId;
int get id => _id;
final FutureOr Function(Client) databaseBuilder;
final FutureOr Function(Client) databaseDestroyer;
Database _database;
Database get database => _database;
bool enableE2eeRecovery;
@deprecated
MatrixApi get api => this;
Encryption encryption;
Set verificationMethods;
Set importantStateEvents;
Set roomPreviewLastEvents;
Set supportedLoginTypes;
int sendMessageTimeoutSeconds;
bool requestHistoryOnLimitedTimeline;
bool formatLocalpart = true;
bool mxidLocalPartFallback = true;
// For CommandsClientExtension
final Map Function(CommandArgs)> commands = {};
final Filter syncFilter;
String syncFilterId;
/// Create a client
/// [clientName] = unique identifier of this client
/// [databaseBuilder]: A function that creates the database instance, that will be used.
/// [databaseDestroyer]: A function that can be used to destroy a database instance, for example by deleting files from disk.
/// [enableE2eeRecovery]: Enable additional logic to try to recover from bad e2ee sessions
/// [verificationMethods]: A set of all the verification methods this client can handle. Includes:
/// KeyVerificationMethod.numbers: Compare numbers. Most basic, should be supported
/// KeyVerificationMethod.emoji: Compare emojis
/// [importantStateEvents]: A set of all the important state events to load when the client connects.
/// To speed up performance only a set of state events is loaded on startup, those that are
/// needed to display a room list. All the remaining state events are automatically post-loaded
/// when opening the timeline of a room or manually by calling `room.postLoad()`.
/// This set will always include the following state events:
/// - m.room.name
/// - m.room.avatar
/// - m.room.message
/// - m.room.encrypted
/// - m.room.encryption
/// - m.room.canonical_alias
/// - m.room.tombstone
/// - *some* m.room.member events, where needed
/// [roomPreviewLastEvents]: The event types that should be used to calculate the last event
/// in a room for the room list.
/// Set [requestHistoryOnLimitedTimeline] to controll the automatic behaviour if the client
/// receives a limited timeline flag for a room.
/// If [mxidLocalPartFallback] is true, then the local part of the mxid will be shown
/// if there is no other displayname available. If not then this will return "Unknown user".
/// If [formatLocalpart] is true, then the localpart of an mxid will
/// be formatted in the way, that all "_" characters are becomming white spaces and
/// the first character of each word becomes uppercase.
/// If your client supports more login types like login with token or SSO, then add this to
/// [supportedLoginTypes]. Set a custom [syncFilter] if you like. By default the app
/// will use lazy_load_members.
Client(
this.clientName, {
this.databaseBuilder,
this.databaseDestroyer,
this.enableE2eeRecovery = false,
this.verificationMethods,
http.Client httpClient,
this.importantStateEvents,
this.roomPreviewLastEvents,
this.pinUnreadRooms = false,
this.sendMessageTimeoutSeconds = 60,
this.requestHistoryOnLimitedTimeline = false,
this.supportedLoginTypes,
Filter syncFilter,
@deprecated bool debug,
}) : syncFilter = syncFilter ??
Filter(
room: RoomFilter(
state: StateFilter(lazyLoadMembers: true),
),
) {
supportedLoginTypes ??= {AuthenticationTypes.password};
verificationMethods ??= {};
importantStateEvents ??= {};
importantStateEvents.addAll([
EventTypes.RoomName,
EventTypes.RoomAvatar,
EventTypes.Message,
EventTypes.Encrypted,
EventTypes.Encryption,
EventTypes.RoomCanonicalAlias,
EventTypes.RoomTombstone,
]);
roomPreviewLastEvents ??= {};
roomPreviewLastEvents.addAll([
EventTypes.Message,
EventTypes.Encrypted,
EventTypes.Sticker,
]);
this.httpClient = httpClient ?? http.Client();
// register all the default commands
registerDefaultCommands();
}
/// The required name for this client.
final String clientName;
/// The Matrix ID of the current logged user.
String get userID => _userID;
String _userID;
/// This points to the position in the synchronization history.
String prevBatch;
/// The device ID is an unique identifier for this device.
String get deviceID => _deviceID;
String _deviceID;
/// The device name is a human readable identifier for this device.
String get deviceName => _deviceName;
String _deviceName;
/// Returns the current login state.
bool isLogged() => accessToken != null;
/// A list of all rooms the user is participating or invited.
List get rooms => _rooms;
List _rooms = [];
/// Whether this client supports end-to-end encryption using olm.
bool get encryptionEnabled => encryption != null && encryption.enabled;
/// Whether this client is able to encrypt and decrypt files.
bool get fileEncryptionEnabled => encryptionEnabled && true;
String get identityKey => encryption?.identityKey ?? '';
String get fingerprintKey => encryption?.fingerprintKey ?? '';
/// Wheather this session is unknown to others
bool get isUnknownSession =>
!userDeviceKeys.containsKey(userID) ||
!userDeviceKeys[userID].deviceKeys.containsKey(deviceID) ||
!userDeviceKeys[userID].deviceKeys[deviceID].signed;
/// Warning! This endpoint is for testing only!
set rooms(List newList) {
Logs().w('Warning! This endpoint is for testing only!');
_rooms = newList;
}
/// Key/Value store of account data.
Map accountData = {};
/// Presences of users by a given matrix ID
Map presences = {};
int _transactionCounter = 0;
String generateUniqueTransactionId() {
_transactionCounter++;
return '$clientName-$_transactionCounter-${DateTime.now().millisecondsSinceEpoch}';
}
Room getRoomByAlias(String alias) {
for (final room in rooms) {
if (room.canonicalAlias == alias) return room;
}
return null;
}
Room getRoomById(String id) {
for (final room in rooms) {
if (room.id == id) return room;
}
return null;
}
Map get directChats =>
accountData['m.direct'] != null ? accountData['m.direct'].content : {};
/// Returns the (first) room ID from the store which is a private chat with the user [userId].
/// Returns null if there is none.
String getDirectChatFromUserId(String userId) {
if (accountData['m.direct'] != null &&
accountData['m.direct'].content[userId] is List &&
accountData['m.direct'].content[userId].length > 0) {
final potentialRooms = accountData['m.direct']
.content[userId]
.cast()
.map(getRoomById)
.where((room) => room != null && room.membership == Membership.join);
if (potentialRooms.isNotEmpty) {
return potentialRooms
.fold(
null,
(prev, r) => prev == null
? r
: (prev.lastEvent.originServerTs <
r.lastEvent.originServerTs
? r
: prev))
.id;
}
}
for (final room in rooms) {
if (room.membership == Membership.invite &&
room.getState(EventTypes.RoomMember, userID)?.senderId == userId &&
room.getState(EventTypes.RoomMember, userID).content['is_direct'] ==
true) {
return room.id;
}
}
return null;
}
/// Gets discovery information about the domain. The file may include additional keys.
Future getWellKnownInformationsByUserId(
String MatrixIdOrDomain,
) async {
final response = await http
.get(Uri.https(MatrixIdOrDomain.domain, '/.well-known/matrix/client'));
var respBody = response.body;
try {
respBody = utf8.decode(response.bodyBytes);
} catch (_) {
// No-OP
}
final rawJson = json.decode(respBody);
return WellKnownInformation.fromJson(rawJson);
}
@Deprecated('Use [checkHomeserver] instead.')
Future checkServer(dynamic serverUrl) async {
try {
await checkHomeserver(serverUrl);
} catch (_) {
return false;
}
return true;
}
/// Checks the supported versions of the Matrix protocol and the supported
/// login types. Throws an exception if the server is not compatible with the
/// client and sets [homeserver] to [homeserverUrl] if it is. Supports the
/// types `Uri` and `String`.
Future checkHomeserver(dynamic homeserverUrl,
{bool checkWellKnown = true}) async {
try {
if (homeserverUrl is Uri) {
homeserver = homeserverUrl;
} else {
// URLs allow to have whitespace surrounding them, see https://www.w3.org/TR/2011/WD-html5-20110525/urls.html
// As we want to strip a trailing slash, though, we have to trim the url ourself
// and thus can't let Uri.parse() deal with it.
homeserverUrl = homeserverUrl.trim();
// strip a trailing slash
if (homeserverUrl.endsWith('/')) {
homeserverUrl = homeserverUrl.substring(0, homeserverUrl.length - 1);
}
homeserver = Uri.parse(homeserverUrl);
}
// Look up well known
WellKnownInformation wellKnown;
if (checkWellKnown) {
try {
wellKnown = await getWellknown();
homeserverUrl = wellKnown.mHomeserver.baseUrl.trim();
// strip a trailing slash
if (homeserverUrl.endsWith('/')) {
homeserverUrl =
homeserverUrl.substring(0, homeserverUrl.length - 1);
}
homeserver = Uri.parse(homeserverUrl);
} catch (e) {
Logs().v('Found no well known information', e);
}
}
// Check if server supports at least one supported version
final versions = await getVersions();
if (!versions.versions
.any((version) => supportedVersions.contains(version))) {
throw BadServerVersionsException(
versions.versions.toSet(), supportedVersions);
}
final loginTypes = await getLoginFlows();
if (!loginTypes.flows.any((f) => supportedLoginTypes.contains(f.type))) {
throw BadServerLoginTypesException(
loginTypes.flows.map((f) => f.type).toSet(), supportedLoginTypes);
}
return wellKnown;
} catch (_) {
homeserver = null;
rethrow;
}
}
/// Checks to see if a username is available, and valid, for the server.
/// Returns the fully-qualified Matrix user ID (MXID) that has been registered.
/// You have to call [checkHomeserver] first to set a homeserver.
@override
Future register({
String username,
String password,
String deviceId,
String initialDeviceDisplayName,
bool inhibitLogin,
AuthenticationData auth,
String kind,
}) async {
final response = await super.register(
username: username,
password: password,
auth: auth,
deviceId: deviceId,
initialDeviceDisplayName: initialDeviceDisplayName,
inhibitLogin: inhibitLogin,
);
// Connect if there is an access token in the response.
if (response.accessToken == null ||
response.deviceId == null ||
response.userId == null) {
throw Exception('Registered but token, device ID or user ID is null.');
}
await init(
newToken: response.accessToken,
newUserID: response.userId,
newHomeserver: homeserver,
newDeviceName: initialDeviceDisplayName ?? '',
newDeviceID: response.deviceId);
return response;
}
/// Handles the login and allows the client to call all APIs which require
/// authentication. Returns false if the login was not successful. Throws
/// MatrixException if login was not successful.
/// To just login with the username 'alice' you set [identifier] to:
/// `AuthenticationUserIdentifier(user: 'alice')`
/// Maybe you want to set [user] to the same String to stay compatible with
/// older server versions.
@override
Future login({
String type = AuthenticationTypes.password,
AuthenticationIdentifier identifier,
String password,
String token,
String deviceId,
String initialDeviceDisplayName,
AuthenticationData auth,
@Deprecated('Deprecated in favour of identifier.') String user,
@Deprecated('Deprecated in favour of identifier.') String medium,
@Deprecated('Deprecated in favour of identifier.') String address,
}) async {
if (homeserver == null && user.isValidMatrixId) {
await checkHomeserver(user.domain);
}
final loginResp = await super.login(
type: type,
identifier: identifier,
password: password,
token: token,
deviceId: deviceId,
initialDeviceDisplayName: initialDeviceDisplayName,
auth: auth,
// ignore: deprecated_member_use
user: user,
// ignore: deprecated_member_use
medium: medium,
// ignore: deprecated_member_use
address: address,
);
// Connect if there is an access token in the response.
if (loginResp.accessToken == null ||
loginResp.deviceId == null ||
loginResp.userId == null) {
throw Exception('Registered but token, device ID or user ID is null.');
}
await init(
newToken: loginResp.accessToken,
newUserID: loginResp.userId,
newHomeserver: homeserver,
newDeviceName: initialDeviceDisplayName ?? '',
newDeviceID: loginResp.deviceId,
);
return loginResp;
}
/// Sends a logout command to the homeserver and clears all local data,
/// including all persistent data from the store.
@override
Future logout() async {
try {
await super.logout();
} catch (e, s) {
Logs().e('Logout failed', e, s);
rethrow;
} finally {
await clear();
}
}
/// Sends a logout command to the homeserver and clears all local data,
/// including all persistent data from the store.
@override
Future logoutAll() async {
try {
await super.logoutAll();
} catch (e, s) {
Logs().e('Logout all failed', e, s);
rethrow;
} finally {
await clear();
}
}
/// Run any request and react on user interactive authentication flows here.
Future uiaRequestBackground(
Future Function(AuthenticationData auth) request) {
final completer = Completer();
UiaRequest uia;
uia = UiaRequest(
request: request,
onUpdate: (state) {
if (state == UiaRequestState.done) {
completer.complete(uia.result);
} else if (state == UiaRequestState.fail) {
completer.completeError(uia.error);
} else {
onUiaRequest.add(uia);
}
},
);
return completer.future;
}
/// Returns an existing direct room ID with this user or creates a new one.
/// Returns null on error.
Future startDirectChat(String mxid) async {
// Try to find an existing direct chat
var roomId = getDirectChatFromUserId(mxid);
if (roomId != null) return roomId;
// Start a new direct chat
roomId = await createRoom(
invite: [mxid],
isDirect: true,
preset: CreateRoomPreset.trusted_private_chat,
);
if (roomId == null) return roomId;
await Room(id: roomId, client: this).addToDirectChat(mxid);
return roomId;
}
/// Creates a new space and returns the Room ID. The parameters are mostly
/// the same like in [createRoom()].
/// Be aware that spaces appear in the [rooms] list. You should check if a
/// room is a space by using the `room.isSpace` getter and then just use the
/// room as a space with `room.toSpace()`.
///
/// https://github.com/matrix-org/matrix-doc/blob/matthew/msc1772/proposals/1772-groups-as-rooms.md
Future createSpace({
String name,
String topic,
Visibility visibility = Visibility.public,
String spaceAliasName,
List invite,
List