Initial commit version 0.8.13

This commit is contained in:
PCoder 2022-04-18 14:27:08 +05:30
commit 9526dfa4f2
111 changed files with 35074 additions and 0 deletions

View file

@ -0,0 +1,237 @@
/*
* Famedly Matrix SDK
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/
import 'dart:async';
import '../../matrix.dart';
extension CommandsClientExtension on Client {
/// Add a command to the command handler. `command` is its name, and `callback` is the
/// callback to invoke
void addCommand(
String command, FutureOr<String?> Function(CommandArgs) callback) {
commands[command.toLowerCase()] = callback;
}
/// Parse and execute a string, `msg` is the input. Optionally `inReplyTo` is the event being
/// replied to and `editEventId` is the eventId of the event being replied to
Future<String?> parseAndRunCommand(Room room, String msg,
{Event? inReplyTo, String? editEventId, String? txid}) async {
final args = CommandArgs(
inReplyTo: inReplyTo,
editEventId: editEventId,
msg: '',
room: room,
txid: txid,
);
if (!msg.startsWith('/')) {
final sendCommand = commands['send'];
if (sendCommand != null) {
args.msg = msg;
return await sendCommand(args);
}
return null;
}
// remove the /
msg = msg.substring(1);
var command = msg;
if (msg.contains(' ')) {
final idx = msg.indexOf(' ');
command = msg.substring(0, idx).toLowerCase();
args.msg = msg.substring(idx + 1);
} else {
command = msg.toLowerCase();
}
final commandOp = commands[command];
if (commandOp != null) {
return await commandOp(args);
}
if (msg.startsWith('/') && commands.containsKey('send')) {
// re-set to include the "command"
final sendCommand = commands['send'];
if (sendCommand != null) {
args.msg = msg;
return await sendCommand(args);
}
}
return null;
}
/// Unregister all commands
void unregisterAllCommands() {
commands.clear();
}
/// Register all default commands
void registerDefaultCommands() {
addCommand('send', (CommandArgs args) async {
return await args.room.sendTextEvent(
args.msg,
inReplyTo: args.inReplyTo,
editEventId: args.editEventId,
parseCommands: false,
txid: args.txid,
);
});
addCommand('me', (CommandArgs args) async {
return await args.room.sendTextEvent(
args.msg,
inReplyTo: args.inReplyTo,
editEventId: args.editEventId,
msgtype: MessageTypes.Emote,
parseCommands: false,
txid: args.txid,
);
});
addCommand('dm', (CommandArgs args) async {
final parts = args.msg.split(' ');
return await args.room.client.startDirectChat(
parts.first,
enableEncryption: !parts.any((part) => part == '--no-encryption'),
);
});
addCommand('create', (CommandArgs args) async {
final parts = args.msg.split(' ');
return await args.room.client.createGroupChat(
enableEncryption: !parts.any((part) => part == '--no-encryption'),
);
});
addCommand('plain', (CommandArgs args) async {
return await args.room.sendTextEvent(
args.msg,
inReplyTo: args.inReplyTo,
editEventId: args.editEventId,
parseMarkdown: false,
parseCommands: false,
txid: args.txid,
);
});
addCommand('html', (CommandArgs args) async {
final event = <String, dynamic>{
'msgtype': 'm.text',
'body': args.msg,
'format': 'org.matrix.custom.html',
'formatted_body': args.msg,
};
return await args.room.sendEvent(
event,
inReplyTo: args.inReplyTo,
editEventId: args.editEventId,
txid: args.txid,
);
});
addCommand('react', (CommandArgs args) async {
final inReplyTo = args.inReplyTo;
if (inReplyTo == null) {
return null;
}
return await args.room.sendReaction(inReplyTo.eventId, args.msg);
});
addCommand('join', (CommandArgs args) async {
await args.room.client.joinRoom(args.msg);
return null;
});
addCommand('leave', (CommandArgs args) async {
await args.room.leave();
return '';
});
addCommand('op', (CommandArgs args) async {
final parts = args.msg.split(' ');
if (parts.isEmpty) {
return null;
}
int? pl;
if (parts.length >= 2) {
pl = int.tryParse(parts[1]);
}
final mxid = parts.first;
return await args.room.setPower(mxid, pl ?? 50);
});
addCommand('kick', (CommandArgs args) async {
final parts = args.msg.split(' ');
await args.room.kick(parts.first);
return '';
});
addCommand('ban', (CommandArgs args) async {
final parts = args.msg.split(' ');
await args.room.ban(parts.first);
return '';
});
addCommand('unban', (CommandArgs args) async {
final parts = args.msg.split(' ');
await args.room.unban(parts.first);
return '';
});
addCommand('invite', (CommandArgs args) async {
final parts = args.msg.split(' ');
await args.room.invite(parts.first);
return '';
});
addCommand('myroomnick', (CommandArgs args) async {
final currentEventJson = args.room
.getState(EventTypes.RoomMember, args.room.client.userID!)
?.content
.copy() ??
{};
currentEventJson['displayname'] = args.msg;
return await args.room.client.setRoomStateWithKey(
args.room.id,
EventTypes.RoomMember,
args.room.client.userID!,
currentEventJson,
);
});
addCommand('myroomavatar', (CommandArgs args) async {
final currentEventJson = args.room
.getState(EventTypes.RoomMember, args.room.client.userID!)
?.content
.copy() ??
{};
currentEventJson['avatar_url'] = args.msg;
return await args.room.client.setRoomStateWithKey(
args.room.id,
EventTypes.RoomMember,
args.room.client.userID!,
currentEventJson,
);
});
addCommand('discardsession', (CommandArgs args) async {
await encryption?.keyManager
.clearOrUseOutboundGroupSession(args.room.id, wipe: true);
return '';
});
addCommand('clearcache', (CommandArgs args) async {
await clearCache();
return '';
});
}
}
class CommandArgs {
String msg;
String? editEventId;
Event? inReplyTo;
Room room;
String? txid;
CommandArgs(
{required this.msg,
this.editEventId,
this.inReplyTo,
required this.room,
this.txid});
}

View file

@ -0,0 +1,29 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
export 'native.dart' if (dart.library.js) 'js.dart';
import 'dart:typed_data';
import 'dart:math';
Uint8List secureRandomBytes(int len) {
final rng = Random.secure();
final list = Uint8List(len);
list.setAll(0, Iterable.generate(list.length, (i) => rng.nextInt(256)));
return list;
}

View file

@ -0,0 +1,60 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import 'dart:typed_data';
import 'dart:convert';
import 'package:matrix/encryption/utils/base64_unpadded.dart';
import 'crypto.dart';
class EncryptedFile {
EncryptedFile({
required this.data,
required this.k,
required this.iv,
required this.sha256,
});
Uint8List data;
String k;
String iv;
String sha256;
}
Future<EncryptedFile> encryptFile(Uint8List input) async {
final key = secureRandomBytes(32);
final iv = secureRandomBytes(16);
final data = await aesCtr.encrypt(input, key, iv);
final hash = await sha256(data);
return EncryptedFile(
data: data,
k: base64Url.encode(key).replaceAll('=', ''),
iv: base64.encode(iv).replaceAll('=', ''),
sha256: base64.encode(hash).replaceAll('=', ''),
);
}
Future<Uint8List?> decryptFile(EncryptedFile input) async {
if (base64.encode(await sha256(input.data)) !=
base64.normalize(input.sha256)) {
return null;
}
final key = base64decodeUnpadded(base64.normalize(input.k));
final iv = base64decodeUnpadded(base64.normalize(input.iv));
return await aesCtr.encrypt(input.data, key, iv);
}

View file

@ -0,0 +1,123 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import 'dart:ffi';
import 'dart:io';
final libcrypto = Platform.isIOS
? DynamicLibrary.process()
: DynamicLibrary.open(Platform.isAndroid
? 'libcrypto.so'
: Platform.isWindows
? 'libcrypto.dll'
: Platform.isMacOS
? 'libcrypto.1.1.dylib'
: 'libcrypto.so.1.1');
final PKCS5_PBKDF2_HMAC = libcrypto.lookupFunction<
IntPtr Function(
Pointer<Uint8> pass,
IntPtr passlen,
Pointer<Uint8> salt,
IntPtr saltlen,
IntPtr iter,
Pointer<NativeType> digest,
IntPtr keylen,
Pointer<Uint8> out),
int Function(
Pointer<Uint8> pass,
int passlen,
Pointer<Uint8> salt,
int saltlen,
int iter,
Pointer<NativeType> digest,
int keylen,
Pointer<Uint8> out)>('PKCS5_PBKDF2_HMAC');
final EVP_sha1 = libcrypto.lookupFunction<Pointer<NativeType> Function(),
Pointer<NativeType> Function()>('EVP_sha1');
final EVP_sha256 = libcrypto.lookupFunction<Pointer<NativeType> Function(),
Pointer<NativeType> Function()>('EVP_sha256');
final EVP_sha512 = libcrypto.lookupFunction<Pointer<NativeType> Function(),
Pointer<NativeType> Function()>('EVP_sha512');
final EVP_aes_128_ctr = libcrypto.lookupFunction<Pointer<NativeType> Function(),
Pointer<NativeType> Function()>('EVP_aes_128_ctr');
final EVP_aes_256_ctr = libcrypto.lookupFunction<Pointer<NativeType> Function(),
Pointer<NativeType> Function()>('EVP_aes_256_ctr');
final EVP_CIPHER_CTX_new = libcrypto.lookupFunction<
Pointer<NativeType> Function(),
Pointer<NativeType> Function()>('EVP_CIPHER_CTX_new');
final EVP_EncryptInit_ex = libcrypto.lookupFunction<
Pointer<NativeType> Function(
Pointer<NativeType> ctx,
Pointer<NativeType> alg,
Pointer<NativeType> some,
Pointer<Uint8> key,
Pointer<Uint8> iv),
Pointer<NativeType> Function(
Pointer<NativeType> ctx,
Pointer<NativeType> alg,
Pointer<NativeType> some,
Pointer<Uint8> key,
Pointer<Uint8> iv)>('EVP_EncryptInit_ex');
final EVP_EncryptUpdate = libcrypto.lookupFunction<
Pointer<NativeType> Function(Pointer<NativeType> ctx, Pointer<Uint8> output,
Pointer<IntPtr> outputLen, Pointer<Uint8> input, IntPtr inputLen),
Pointer<NativeType> Function(
Pointer<NativeType> ctx,
Pointer<Uint8> output,
Pointer<IntPtr> outputLen,
Pointer<Uint8> input,
int inputLen)>('EVP_EncryptUpdate');
final EVP_EncryptFinal_ex = libcrypto.lookupFunction<
Pointer<NativeType> Function(
Pointer<NativeType> ctx, Pointer<Uint8> data, Pointer<IntPtr> len),
Pointer<NativeType> Function(Pointer<NativeType> ctx, Pointer<Uint8> data,
Pointer<IntPtr> len)>('EVP_EncryptFinal_ex');
final EVP_CIPHER_CTX_free = libcrypto.lookupFunction<
Pointer<NativeType> Function(Pointer<NativeType> ctx),
Pointer<NativeType> Function(
Pointer<NativeType> ctx)>('EVP_CIPHER_CTX_free');
final EVP_Digest = libcrypto.lookupFunction<
IntPtr Function(
Pointer<Uint8> data,
IntPtr len,
Pointer<Uint8> hash,
Pointer<IntPtr> hsize,
Pointer<NativeType> alg,
Pointer<NativeType> engine),
int Function(
Pointer<Uint8> data,
int len,
Pointer<Uint8> hash,
Pointer<IntPtr> hsize,
Pointer<NativeType> alg,
Pointer<NativeType> engine)>('EVP_Digest');
final EVP_MD_size = libcrypto.lookupFunction<
IntPtr Function(Pointer<NativeType> ctx),
int Function(Pointer<NativeType> ctx)>('EVP_MD_size');

View file

@ -0,0 +1,64 @@
// Copyright (c) 2020 Famedly GmbH
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:typed_data';
import 'subtle.dart';
import 'subtle.dart' as subtle;
abstract class Hash {
Hash._(this.name);
String name;
Future<Uint8List> call(Uint8List input) async =>
Uint8List.view(await digest(name, input));
}
final Hash sha1 = _Sha1();
final Hash sha256 = _Sha256();
final Hash sha512 = _Sha512();
class _Sha1 extends Hash {
_Sha1() : super._('SHA-1');
}
class _Sha256 extends Hash {
_Sha256() : super._('SHA-256');
}
class _Sha512 extends Hash {
_Sha512() : super._('SHA-512');
}
abstract class Cipher {
Cipher._(this.name);
String name;
Object params(Uint8List iv);
Future<Uint8List> encrypt(
Uint8List input, Uint8List key, Uint8List iv) async {
final subtleKey = await importKey('raw', key, name, false, ['encrypt']);
return (await subtle.encrypt(params(iv), subtleKey, input)).asUint8List();
}
}
final Cipher aesCtr = _AesCtr();
class _AesCtr extends Cipher {
_AesCtr() : super._('AES-CTR');
@override
Object params(Uint8List iv) =>
AesCtrParams(name: name, counter: iv, length: 64);
}
Future<Uint8List> pbkdf2(Uint8List passphrase, Uint8List salt, Hash hash,
int iterations, int bits) async {
final raw =
await importKey('raw', passphrase, 'PBKDF2', false, ['deriveBits']);
final res = await deriveBits(
Pbkdf2Params(
name: 'PBKDF2', hash: hash.name, salt: salt, iterations: iterations),
raw,
bits);
return Uint8List.view(res);
}

View file

@ -0,0 +1,102 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:ffi';
import 'package:ffi/ffi.dart';
import 'ffi.dart';
abstract class Hash {
Hash._(this.ptr);
Pointer<NativeType> ptr;
FutureOr<Uint8List> call(Uint8List data) {
final outSize = EVP_MD_size(ptr);
final mem = malloc.call<Uint8>(outSize + data.length);
final dataMem = mem.elementAt(outSize);
try {
dataMem.asTypedList(data.length).setAll(0, data);
EVP_Digest(dataMem, data.length, mem, nullptr, ptr, nullptr);
return Uint8List.fromList(mem.asTypedList(outSize));
} finally {
malloc.free(mem);
}
}
}
final Hash sha1 = _Sha1();
final Hash sha256 = _Sha256();
final Hash sha512 = _Sha512();
class _Sha1 extends Hash {
_Sha1() : super._(EVP_sha1());
}
class _Sha256 extends Hash {
_Sha256() : super._(EVP_sha256());
}
class _Sha512 extends Hash {
_Sha512() : super._(EVP_sha512());
}
abstract class Cipher {
Cipher._();
Pointer<NativeType> getAlg(int keysize);
FutureOr<Uint8List> encrypt(Uint8List input, Uint8List key, Uint8List iv) {
final alg = getAlg(key.length * 8);
final mem = malloc
.call<Uint8>(sizeOf<IntPtr>() + key.length + iv.length + input.length);
final lenMem = mem.cast<IntPtr>();
final keyMem = mem.elementAt(sizeOf<IntPtr>());
final ivMem = keyMem.elementAt(key.length);
final dataMem = ivMem.elementAt(iv.length);
try {
keyMem.asTypedList(key.length).setAll(0, key);
ivMem.asTypedList(iv.length).setAll(0, iv);
dataMem.asTypedList(input.length).setAll(0, input);
final ctx = EVP_CIPHER_CTX_new();
EVP_EncryptInit_ex(ctx, alg, nullptr, keyMem, ivMem);
EVP_EncryptUpdate(ctx, dataMem, lenMem, dataMem, input.length);
EVP_EncryptFinal_ex(ctx, dataMem.elementAt(lenMem.value), lenMem);
EVP_CIPHER_CTX_free(ctx);
return Uint8List.fromList(dataMem.asTypedList(input.length));
} finally {
malloc.free(mem);
}
}
}
final Cipher aesCtr = _AesCtr();
class _AesCtr extends Cipher {
_AesCtr() : super._();
@override
Pointer<NativeType> getAlg(int keysize) {
switch (keysize) {
case 128:
return EVP_aes_128_ctr();
case 256:
return EVP_aes_256_ctr();
default:
throw ArgumentError('invalid key size');
}
}
}
FutureOr<Uint8List> pbkdf2(
Uint8List passphrase, Uint8List salt, Hash hash, int iterations, int bits) {
final outLen = bits ~/ 8;
final mem = malloc.call<Uint8>(passphrase.length + salt.length + outLen);
final saltMem = mem.elementAt(passphrase.length);
final outMem = saltMem.elementAt(salt.length);
try {
mem.asTypedList(passphrase.length).setAll(0, passphrase);
saltMem.asTypedList(salt.length).setAll(0, salt);
PKCS5_PBKDF2_HMAC(mem, passphrase.length, saltMem, salt.length, iterations,
hash.ptr, outLen, outMem);
return Uint8List.fromList(outMem.asTypedList(outLen));
} finally {
malloc.free(mem);
}
}

View file

@ -0,0 +1,93 @@
// Copyright (c) 2020 Famedly GmbH
// SPDX-License-Identifier: AGPL-3.0-or-later
@JS()
library subtle;
import 'package:js/js.dart';
import 'dart:async';
import 'dart:js_util';
import 'dart:typed_data';
@JS()
@anonymous
class Pbkdf2Params {
external factory Pbkdf2Params({
String name,
String hash,
Uint8List salt,
int iterations,
});
String? name;
String? hash;
Uint8List? salt;
int? iterations;
}
@JS()
@anonymous
class AesCtrParams {
external factory AesCtrParams({
String name,
Uint8List counter,
int length,
});
String? name;
Uint8List? counter;
int? length;
}
@JS('crypto.subtle.encrypt')
external dynamic _encrypt(dynamic algorithm, dynamic key, Uint8List data);
Future<ByteBuffer> encrypt(dynamic algorithm, dynamic key, Uint8List data) {
return promiseToFuture(_encrypt(algorithm, key, data));
}
@JS('crypto.subtle.decrypt')
external dynamic _decrypt(dynamic algorithm, dynamic key, Uint8List data);
Future<ByteBuffer> decrypt(dynamic algorithm, dynamic key, Uint8List data) {
return promiseToFuture(_decrypt(algorithm, key, data));
}
@JS('crypto.subtle.importKey')
external dynamic _importKey(String format, dynamic keyData, dynamic algorithm,
bool extractable, List<String> keyUsages);
Future<dynamic> importKey(String format, dynamic keyData, dynamic algorithm,
bool extractable, List<String> keyUsages) {
return promiseToFuture(
_importKey(format, keyData, algorithm, extractable, keyUsages));
}
@JS('crypto.subtle.exportKey')
external dynamic _exportKey(String algorithm, dynamic key);
Future<dynamic> exportKey(String algorithm, dynamic key) {
return promiseToFuture(_exportKey(algorithm, key));
}
@JS('crypto.subtle.deriveKey')
external dynamic _deriveKey(dynamic algorithm, dynamic baseKey,
dynamic derivedKeyAlgorithm, bool extractable, List<String> keyUsages);
Future<ByteBuffer> deriveKey(dynamic algorithm, dynamic baseKey,
dynamic derivedKeyAlgorithm, bool extractable, List<String> keyUsages) {
return promiseToFuture(_deriveKey(
algorithm, baseKey, derivedKeyAlgorithm, extractable, keyUsages));
}
@JS('crypto.subtle.deriveBits')
external dynamic _deriveBits(dynamic algorithm, dynamic baseKey, int length);
Future<ByteBuffer> deriveBits(dynamic algorithm, dynamic baseKey, int length) {
return promiseToFuture(_deriveBits(algorithm, baseKey, length));
}
@JS('crypto.subtle.digest')
external dynamic _digest(String algorithm, Uint8List data);
Future<ByteBuffer> digest(String algorithm, Uint8List data) {
return promiseToFuture(_digest(algorithm, data));
}

View file

@ -0,0 +1,515 @@
/*
* Famedly Matrix SDK
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/
import 'dart:convert';
import 'package:canonical_json/canonical_json.dart';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:matrix/matrix.dart';
import 'package:olm/olm.dart' as olm;
import '../../encryption.dart';
enum UserVerifiedStatus { verified, unknown, unknownDevice }
class DeviceKeysList {
Client client;
String userId;
bool outdated = true;
Map<String, DeviceKeys> deviceKeys = {};
Map<String, CrossSigningKey> crossSigningKeys = {};
SignableKey? getKey(String id) => deviceKeys[id] ?? crossSigningKeys[id];
CrossSigningKey? getCrossSigningKey(String type) =>
crossSigningKeys.values.firstWhereOrNull((k) => k.usage.contains(type));
CrossSigningKey? get masterKey => getCrossSigningKey('master');
CrossSigningKey? get selfSigningKey => getCrossSigningKey('self_signing');
CrossSigningKey? get userSigningKey => getCrossSigningKey('user_signing');
UserVerifiedStatus get verified {
if (masterKey == null) {
return UserVerifiedStatus.unknown;
}
if (masterKey!.verified) {
for (final key in deviceKeys.values) {
if (!key.verified) {
return UserVerifiedStatus.unknownDevice;
}
}
return UserVerifiedStatus.verified;
} else {
for (final key in deviceKeys.values) {
if (!key.verified) {
return UserVerifiedStatus.unknown;
}
}
return UserVerifiedStatus.verified;
}
}
/// Starts a verification with this device. This might need to create a new
/// direct chat to send the verification request over this room. For this you
/// can set parameters here.
Future<KeyVerification> startVerification({
bool? newDirectChatEnableEncryption,
List<StateEvent>? newDirectChatInitialState,
}) async {
final encryption = client.encryption;
if (encryption == null) {
throw Exception('Encryption not enabled');
}
if (userId != client.userID) {
// in-room verification with someone else
final roomId = await client.startDirectChat(
userId,
enableEncryption: newDirectChatEnableEncryption,
initialState: newDirectChatInitialState,
waitForSync: false,
);
final room =
client.getRoomById(roomId) ?? Room(id: roomId, client: client);
final request =
KeyVerification(encryption: encryption, room: room, userId: userId);
await request.start();
// no need to add to the request client object. As we are doing a room
// verification request that'll happen automatically once we know the transaction id
return request;
} else {
// broadcast self-verification
final request = KeyVerification(
encryption: encryption, userId: userId, deviceId: '*');
await request.start();
encryption.keyVerificationManager.addRequest(request);
return request;
}
}
DeviceKeysList.fromDbJson(
Map<String, dynamic> dbEntry,
List<Map<String, dynamic>> childEntries,
List<Map<String, dynamic>> crossSigningEntries,
Client cl)
: client = cl,
userId = dbEntry['user_id'] ?? '' {
outdated = dbEntry['outdated'];
deviceKeys = {};
for (final childEntry in childEntries) {
final entry = DeviceKeys.fromDb(childEntry, client);
if (entry.isValid) {
deviceKeys[childEntry['device_id']] = entry;
} else {
outdated = true;
}
}
for (final crossSigningEntry in crossSigningEntries) {
final entry = CrossSigningKey.fromDbJson(crossSigningEntry, client);
if (entry.isValid) {
crossSigningKeys[crossSigningEntry['public_key']] = entry;
} else {
outdated = true;
}
}
}
DeviceKeysList(this.userId, this.client);
}
class SimpleSignableKey extends MatrixSignableKey {
@override
String? identifier;
SimpleSignableKey.fromJson(Map<String, dynamic> json) : super.fromJson(json);
}
abstract class SignableKey extends MatrixSignableKey {
Client client;
Map<String, dynamic>? validSignatures;
bool? _verified;
bool? _blocked;
String? get ed25519Key => keys['ed25519:$identifier'];
bool get verified =>
identifier != null && (directVerified || crossVerified) && !(blocked);
bool get blocked => _blocked ?? false;
set blocked(bool b) => _blocked = b;
bool get encryptToDevice =>
!(blocked) &&
identifier != null &&
ed25519Key != null &&
(client.userDeviceKeys[userId]?.masterKey?.verified ?? false
? verified
: true);
void setDirectVerified(bool v) {
_verified = v;
}
bool get directVerified => _verified ?? false;
bool get crossVerified => hasValidSignatureChain();
bool get signed => hasValidSignatureChain(verifiedOnly: false);
SignableKey.fromJson(Map<String, dynamic> json, Client cl)
: client = cl,
super.fromJson(json) {
_verified = false;
_blocked = false;
}
SimpleSignableKey cloneForSigning() {
final newKey = SimpleSignableKey.fromJson(toJson().copy());
newKey.identifier = identifier;
(newKey.signatures ??= {}).clear();
return newKey;
}
String get signingContent {
final data = super.toJson().copy();
// some old data might have the custom verified and blocked keys
data.remove('verified');
data.remove('blocked');
// remove the keys not needed for signing
data.remove('unsigned');
data.remove('signatures');
return String.fromCharCodes(canonicalJson.encode(data));
}
bool _verifySignature(String pubKey, String signature,
{bool isSignatureWithoutLibolmValid = false}) {
olm.Utility olmutil;
try {
olmutil = olm.Utility();
} catch (e) {
// if no libolm is present we land in this catch block, and return the default
// set if no libolm is there. Some signatures should be assumed-valid while others
// should be assumed-invalid
return isSignatureWithoutLibolmValid;
}
var valid = false;
try {
olmutil.ed25519_verify(pubKey, signingContent, signature);
valid = true;
} catch (_) {
// bad signature
valid = false;
} finally {
olmutil.free();
}
return valid;
}
bool hasValidSignatureChain(
{bool verifiedOnly = true,
Set<String>? visited,
Set<String>? onlyValidateUserIds}) {
if (!client.encryptionEnabled) {
return false;
}
final visited_ = visited ?? <String>{};
final onlyValidateUserIds_ = onlyValidateUserIds ?? <String>{};
final setKey = '$userId;$identifier';
if (visited_.contains(setKey) ||
(onlyValidateUserIds_.isNotEmpty &&
!onlyValidateUserIds_.contains(userId))) {
return false; // prevent recursion & validate hasValidSignatureChain
}
visited_.add(setKey);
if (signatures == null) return false;
for (final signatureEntries in signatures!.entries) {
final otherUserId = signatureEntries.key;
if (!client.userDeviceKeys.containsKey(otherUserId)) {
continue;
}
// we don't allow transitive trust unless it is for ourself
if (otherUserId != userId && otherUserId != client.userID) {
continue;
}
for (final signatureEntry in signatureEntries.value.entries) {
final fullKeyId = signatureEntry.key;
final signature = signatureEntry.value;
final keyId = fullKeyId.substring('ed25519:'.length);
// we ignore self-signatures here
if (otherUserId == userId && keyId == identifier) {
continue;
}
final key = client.userDeviceKeys[otherUserId]?.deviceKeys[keyId] ??
client.userDeviceKeys[otherUserId]?.crossSigningKeys[keyId];
if (key == null) {
continue;
}
if (onlyValidateUserIds_.isNotEmpty &&
!onlyValidateUserIds_.contains(key.userId)) {
// we don't want to verify keys from this user
continue;
}
if (key.blocked) {
continue; // we can't be bothered about this keys signatures
}
var haveValidSignature = false;
var gotSignatureFromCache = false;
final fullKeyIdBool = validSignatures
?.tryGetMap<String, dynamic>(otherUserId)
?.tryGet<bool>(fullKeyId);
if (fullKeyIdBool == true) {
haveValidSignature = true;
gotSignatureFromCache = true;
} else if (fullKeyIdBool == false) {
haveValidSignature = false;
gotSignatureFromCache = true;
}
if (!gotSignatureFromCache && key.ed25519Key != null) {
// validate the signature manually
haveValidSignature = _verifySignature(key.ed25519Key!, signature);
final validSignatures = this.validSignatures ??= <String, dynamic>{};
if (!validSignatures.containsKey(otherUserId)) {
validSignatures[otherUserId] = <String, dynamic>{};
}
validSignatures[otherUserId][fullKeyId] = haveValidSignature;
}
if (!haveValidSignature) {
// no valid signature, this key is useless
continue;
}
if ((verifiedOnly && key.directVerified) ||
(key is CrossSigningKey &&
key.usage.contains('master') &&
key.directVerified &&
key.userId == client.userID)) {
return true; // we verified this key and it is valid...all checks out!
}
// or else we just recurse into that key and chack if it works out
final haveChain = key.hasValidSignatureChain(
verifiedOnly: verifiedOnly,
visited: visited_,
onlyValidateUserIds: onlyValidateUserIds);
if (haveChain) {
return true;
}
}
}
return false;
}
Future<void> setVerified(bool newVerified, [bool sign = true]) async {
_verified = newVerified;
final encryption = client.encryption;
if (newVerified &&
sign &&
encryption != null &&
client.encryptionEnabled &&
encryption.crossSigning.signable([this])) {
// sign the key!
// ignore: unawaited_futures
encryption.crossSigning.sign([this]);
}
}
Future<void> setBlocked(bool newBlocked);
@override
Map<String, dynamic> toJson() {
final data = super.toJson().copy();
// some old data may have the verified and blocked keys which are unneeded now
data.remove('verified');
data.remove('blocked');
return data;
}
@override
String toString() => json.encode(toJson());
@override
bool operator ==(dynamic other) => (other is SignableKey &&
other.userId == userId &&
other.identifier == identifier);
}
class CrossSigningKey extends SignableKey {
@override
String? identifier;
String? get publicKey => identifier;
late List<String> usage;
bool get isValid =>
userId.isNotEmpty &&
publicKey != null &&
keys.isNotEmpty &&
ed25519Key != null;
@override
Future<void> setVerified(bool newVerified, [bool sign = true]) async {
if (!isValid) {
throw Exception('setVerified called on invalid key');
}
await super.setVerified(newVerified, sign);
await client.database
?.setVerifiedUserCrossSigningKey(newVerified, userId, publicKey!);
}
@override
Future<void> setBlocked(bool newBlocked) async {
if (!isValid) {
throw Exception('setBlocked called on invalid key');
}
_blocked = newBlocked;
await client.database
?.setBlockedUserCrossSigningKey(newBlocked, userId, publicKey!);
}
CrossSigningKey.fromMatrixCrossSigningKey(MatrixCrossSigningKey k, Client cl)
: super.fromJson(k.toJson().copy(), cl) {
final json = toJson();
identifier = k.publicKey;
usage = json['usage'].cast<String>();
}
CrossSigningKey.fromDbJson(Map<String, dynamic> dbEntry, Client cl)
: super.fromJson(Event.getMapFromPayload(dbEntry['content']), cl) {
final json = toJson();
identifier = dbEntry['public_key'];
usage = json['usage'].cast<String>();
_verified = dbEntry['verified'];
_blocked = dbEntry['blocked'];
}
CrossSigningKey.fromJson(Map<String, dynamic> json, Client cl)
: super.fromJson(json.copy(), cl) {
final json = toJson();
usage = json['usage'].cast<String>();
if (keys.isNotEmpty) {
identifier = keys.values.first;
}
}
}
class DeviceKeys extends SignableKey {
@override
String? identifier;
String? get deviceId => identifier;
late List<String> algorithms;
late DateTime lastActive;
String? get curve25519Key => keys['curve25519:$deviceId'];
String? get deviceDisplayName => unsigned?['device_display_name'];
bool? _validSelfSignature;
bool get selfSigned =>
_validSelfSignature ??
(_validSelfSignature = (deviceId != null &&
signatures
?.tryGetMap<String, dynamic>(userId)
?.tryGet<String>('ed25519:$deviceId') ==
null
? false
// without libolm we still want to be able to add devices. In that case we ofc just can't
// verify the signature
: _verifySignature(
ed25519Key!, signatures![userId]!['ed25519:$deviceId']!,
isSignatureWithoutLibolmValid: true)));
@override
bool get blocked => super.blocked || !selfSigned;
bool get isValid =>
deviceId != null &&
keys.isNotEmpty &&
curve25519Key != null &&
ed25519Key != null &&
selfSigned;
@override
Future<void> setVerified(bool newVerified, [bool sign = true]) async {
if (!isValid) {
//throw Exception('setVerified called on invalid key');
return;
}
await super.setVerified(newVerified, sign);
await client.database
?.setVerifiedUserDeviceKey(newVerified, userId, deviceId!);
}
@override
Future<void> setBlocked(bool newBlocked) async {
if (!isValid) {
//throw Exception('setBlocked called on invalid key');
return;
}
_blocked = newBlocked;
await client.database
?.setBlockedUserDeviceKey(newBlocked, userId, deviceId!);
}
DeviceKeys.fromMatrixDeviceKeys(MatrixDeviceKeys k, Client cl,
[DateTime? lastActiveTs])
: super.fromJson(k.toJson().copy(), cl) {
final json = toJson();
identifier = k.deviceId;
algorithms = json['algorithms'].cast<String>();
lastActive = lastActiveTs ?? DateTime.now();
}
DeviceKeys.fromDb(Map<String, dynamic> dbEntry, Client cl)
: super.fromJson(Event.getMapFromPayload(dbEntry['content']), cl) {
final json = toJson();
identifier = dbEntry['device_id'];
algorithms = json['algorithms'].cast<String>();
_verified = dbEntry['verified'];
_blocked = dbEntry['blocked'];
lastActive =
DateTime.fromMillisecondsSinceEpoch(dbEntry['last_active'] ?? 0);
}
DeviceKeys.fromJson(Map<String, dynamic> json, Client cl)
: super.fromJson(json.copy(), cl) {
final json = toJson();
identifier = json['device_id'];
algorithms = json['algorithms'].cast<String>();
lastActive = DateTime.fromMillisecondsSinceEpoch(0);
}
KeyVerification startVerification() {
if (!isValid) {
throw Exception('setVerification called on invalid key');
}
final encryption = client.encryption;
if (encryption == null) {
throw Exception('setVerification called with disabled encryption');
}
final request = KeyVerification(
encryption: encryption, userId: userId, deviceId: deviceId!);
request.start();
encryption.keyVerificationManager.addRequest(request);
return request;
}
}

View file

@ -0,0 +1,224 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import 'package:collection/collection.dart';
import '../../encryption.dart';
import '../../matrix.dart';
abstract class EventLocalizations {
// As we need to create the localized body off of a different set of parameters, we
// might create it with `event.plaintextBody`, maybe with `event.body`, maybe with the
// reply fallback stripped, and maybe with the new body in `event.content['m.new_content']`.
// Thus, it seems easier to offload that logic into `Event.getLocalizedBody()` and pass the
// `body` variable around here.
static String _localizedBodyNormalMessage(
Event event, MatrixLocalizations i18n, String body) {
switch (event.messageType) {
case MessageTypes.Image:
return i18n.sentAPicture(event.sender.calcDisplayname());
case MessageTypes.File:
return i18n.sentAFile(event.sender.calcDisplayname());
case MessageTypes.Audio:
return i18n.sentAnAudio(event.sender.calcDisplayname());
case MessageTypes.Video:
return i18n.sentAVideo(event.sender.calcDisplayname());
case MessageTypes.Location:
return i18n.sharedTheLocation(event.sender.calcDisplayname());
case MessageTypes.Sticker:
return i18n.sentASticker(event.sender.calcDisplayname());
case MessageTypes.Emote:
return '* $body';
case MessageTypes.BadEncrypted:
String errorText;
switch (event.body) {
case DecryptException.channelCorrupted:
errorText = i18n.channelCorruptedDecryptError + '.';
break;
case DecryptException.notEnabled:
errorText = i18n.encryptionNotEnabled + '.';
break;
case DecryptException.unknownAlgorithm:
errorText = i18n.unknownEncryptionAlgorithm + '.';
break;
case DecryptException.unknownSession:
errorText = i18n.noPermission + '.';
break;
default:
errorText = body;
break;
}
return i18n.couldNotDecryptMessage(errorText);
case MessageTypes.Text:
case MessageTypes.Notice:
case MessageTypes.None:
default:
return body;
}
}
// This map holds how to localize event types, and thus which event types exist.
// If an event exists but it does not have a localized body, set its callback to null
static final Map<String,
String Function(Event event, MatrixLocalizations i18n, String body)?>
localizationsMap = {
EventTypes.Sticker: (event, i18n, body) =>
i18n.sentASticker(event.sender.calcDisplayname()),
EventTypes.Redaction: (event, i18n, body) =>
i18n.redactedAnEvent(event.sender.calcDisplayname()),
EventTypes.RoomAliases: (event, i18n, body) =>
i18n.changedTheRoomAliases(event.sender.calcDisplayname()),
EventTypes.RoomCanonicalAlias: (event, i18n, body) =>
i18n.changedTheRoomInvitationLink(event.sender.calcDisplayname()),
EventTypes.RoomCreate: (event, i18n, body) =>
i18n.createdTheChat(event.sender.calcDisplayname()),
EventTypes.RoomTombstone: (event, i18n, body) => i18n.roomHasBeenUpgraded,
EventTypes.RoomJoinRules: (event, i18n, body) {
final joinRules = JoinRules.values.firstWhereOrNull((r) =>
r.toString().replaceAll('JoinRules.', '') ==
event.content['join_rule']);
if (joinRules == null) {
return i18n.changedTheJoinRules(event.sender.calcDisplayname());
} else {
return i18n.changedTheJoinRulesTo(
event.sender.calcDisplayname(), joinRules.getLocalizedString(i18n));
}
},
EventTypes.RoomMember: (event, i18n, body) {
var text = 'Failed to parse member event';
final targetName = event.stateKeyUser?.calcDisplayname() ?? '';
// Has the membership changed?
final newMembership = event.content['membership'] ?? '';
final oldMembership = event.prevContent?['membership'] ?? '';
if (newMembership != oldMembership) {
if (oldMembership == 'invite' && newMembership == 'join') {
text = i18n.acceptedTheInvitation(targetName);
} else if (oldMembership == 'invite' && newMembership == 'leave') {
if (event.stateKey == event.senderId) {
text = i18n.rejectedTheInvitation(targetName);
} else {
text = i18n.hasWithdrawnTheInvitationFor(
event.sender.calcDisplayname(), targetName);
}
} else if (oldMembership == 'leave' && newMembership == 'join') {
text = i18n.joinedTheChat(targetName);
} else if (oldMembership == 'join' && newMembership == 'ban') {
text =
i18n.kickedAndBanned(event.sender.calcDisplayname(), targetName);
} else if (oldMembership == 'join' &&
newMembership == 'leave' &&
event.stateKey != event.senderId) {
text = i18n.kicked(event.sender.calcDisplayname(), targetName);
} else if (oldMembership == 'join' &&
newMembership == 'leave' &&
event.stateKey == event.senderId) {
text = i18n.userLeftTheChat(targetName);
} else if (oldMembership == 'invite' && newMembership == 'ban') {
text = i18n.bannedUser(event.sender.calcDisplayname(), targetName);
} else if (oldMembership == 'leave' && newMembership == 'ban') {
text = i18n.bannedUser(event.sender.calcDisplayname(), targetName);
} else if (oldMembership == 'ban' && newMembership == 'leave') {
text = i18n.unbannedUser(event.sender.calcDisplayname(), targetName);
} else if (newMembership == 'invite') {
text = i18n.invitedUser(event.sender.calcDisplayname(), targetName);
} else if (newMembership == 'join') {
text = i18n.joinedTheChat(targetName);
}
} else if (newMembership == 'join') {
final newAvatar = event.content.tryGet<String>('avatar_url') ?? '';
final oldAvatar = event.prevContent?.tryGet<String>('avatar_url') ?? '';
final newDisplayname =
event.content.tryGet<String>('displayname') ?? '';
final oldDisplayname =
event.prevContent?.tryGet<String>('displayname') ?? '';
final stateKey = event.stateKey;
// Has the user avatar changed?
if (newAvatar != oldAvatar) {
text = i18n.changedTheProfileAvatar(targetName);
}
// Has the user displayname changed?
else if (newDisplayname != oldDisplayname && stateKey != null) {
text = i18n.changedTheDisplaynameTo(oldDisplayname, newDisplayname);
}
}
return text;
},
EventTypes.RoomPowerLevels: (event, i18n, body) =>
i18n.changedTheChatPermissions(event.sender.calcDisplayname()),
EventTypes.RoomName: (event, i18n, body) => i18n.changedTheChatNameTo(
event.sender.calcDisplayname(), event.content['name']),
EventTypes.RoomTopic: (event, i18n, body) =>
i18n.changedTheChatDescriptionTo(
event.sender.calcDisplayname(), event.content['topic']),
EventTypes.RoomAvatar: (event, i18n, body) =>
i18n.changedTheChatAvatar(event.sender.calcDisplayname()),
EventTypes.GuestAccess: (event, i18n, body) {
final guestAccess = GuestAccess.values.firstWhereOrNull((r) =>
r.toString().replaceAll('GuestAccess.', '') ==
event.content['guest_access']);
if (guestAccess == null) {
return i18n.changedTheGuestAccessRules(event.sender.calcDisplayname());
} else {
return i18n.changedTheGuestAccessRulesTo(event.sender.calcDisplayname(),
guestAccess.getLocalizedString(i18n));
}
},
EventTypes.HistoryVisibility: (event, i18n, body) {
final historyVisibility = HistoryVisibility.values.firstWhereOrNull((r) =>
r.toString().replaceAll('HistoryVisibility.', '') ==
event.content['history_visibility']);
if (historyVisibility == null) {
return i18n.changedTheHistoryVisibility(event.sender.calcDisplayname());
} else {
return i18n.changedTheHistoryVisibilityTo(
event.sender.calcDisplayname(),
historyVisibility.getLocalizedString(i18n));
}
},
EventTypes.Encryption: (event, i18n, body) {
var localizedBody =
i18n.activatedEndToEndEncryption(event.sender.calcDisplayname());
if (event.room.client.encryptionEnabled == false) {
localizedBody += '. ' + i18n.needPantalaimonWarning;
}
return localizedBody;
},
EventTypes.CallAnswer: (event, i18n, body) =>
i18n.answeredTheCall(event.sender.calcDisplayname()),
EventTypes.CallHangup: (event, i18n, body) =>
i18n.endedTheCall(event.sender.calcDisplayname()),
EventTypes.CallInvite: (event, i18n, body) =>
i18n.startedACall(event.sender.calcDisplayname()),
EventTypes.CallCandidates: (event, i18n, body) =>
i18n.sentCallInformations(event.sender.calcDisplayname()),
EventTypes.Encrypted: (event, i18n, body) =>
_localizedBodyNormalMessage(event, i18n, body),
EventTypes.Message: (event, i18n, body) =>
_localizedBodyNormalMessage(event, i18n, body),
EventTypes.Reaction: (event, i18n, body) => i18n.sentReaction(
event.sender.calcDisplayname(),
event.content
.tryGetMap<String, dynamic>('m.relates_to')
?.tryGet<String>('key') ??
body,
),
};
}

View file

@ -0,0 +1,72 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import '../../matrix.dart';
enum EventUpdateType {
timeline,
state,
history,
accountData,
ephemeral,
inviteState
}
/// Represents a new event (e.g. a message in a room) or an update for an
/// already known event.
class EventUpdate {
/// Usually 'timeline', 'state' or whatever.
final EventUpdateType type;
/// Most events belong to a room. If not, this equals to eventType.
final String roomID;
@Deprecated("Use `content['type']` instead.")
String get eventType => content['type'];
// The json payload of the content of this event.
final Map<String, dynamic> content;
EventUpdate({
required this.roomID,
required this.type,
required this.content,
});
Future<EventUpdate> decrypt(Room room, {bool store = false}) async {
final encryption = room.client.encryption;
if (content['type'] != EventTypes.Encrypted ||
!room.client.encryptionEnabled ||
encryption == null) {
return this;
}
try {
final decrpytedEvent = await encryption.decryptRoomEvent(
room.id, Event.fromJson(content, room),
store: store, updateType: type);
return EventUpdate(
roomID: roomID,
type: type,
content: decrpytedEvent.toJson(),
);
} catch (e, s) {
Logs().e('[LibOlm] Could not decrypt megolm event', e, s);
return this;
}
}
}

View file

@ -0,0 +1,259 @@
/*
* Famedly Matrix SDK
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/
import 'package:collection/collection.dart';
import 'package:html/parser.dart';
import 'package:html/dom.dart';
import 'package:html_unescape/html_unescape.dart';
class HtmlToText {
/// Convert an HTML string to a pseudo-markdown plain text representation, with
/// `data-mx-spoiler` spans redacted
static String convert(String html) {
// riot-web is notorious for creating bad reply fallback events from invalid messages which, if
// not handled properly, can lead to impersonation. As such, we strip the entire `<mx-reply>` tags
// here already, to prevent that from happening.
// We do *not* do this in an AST and just with simple regex here, as riot-web tends to create
// miss-matching tags, and this way we actually correctly identify what we want to strip and, well,
// strip it.
final renderHtml = html.replaceAll(
RegExp('<mx-reply>.*<\/mx-reply>',
caseSensitive: false, multiLine: false, dotAll: true),
'');
final opts = _ConvertOpts();
var reply = _walkNode(opts, parseFragment(renderHtml));
reply = reply.replaceAll(RegExp(r'\s*$', multiLine: false), '');
return reply;
}
static String _parsePreContent(_ConvertOpts opts, Element node) {
var text = node.innerHtml;
final match =
RegExp(r'^<code([^>]*)>', multiLine: false, caseSensitive: false)
.firstMatch(text);
if (match == null) {
text = HtmlUnescape().convert(text);
if (text.isNotEmpty) {
if (text[0] != '\n') {
text = '\n$text';
}
if (text[text.length - 1] != '\n') {
text += '\n';
}
}
return text;
}
// remove <code> opening tag
text = text.substring(match.end);
// remove the </code> closing tag
text = text.replaceAll(
RegExp(r'</code>$', multiLine: false, caseSensitive: false), '');
text = HtmlUnescape().convert(text);
if (text.isNotEmpty) {
if (text[0] != '\n') {
text = '\n$text';
}
if (text[text.length - 1] != '\n') {
text += '\n';
}
}
final language =
RegExp(r'language-(\w+)', multiLine: false, caseSensitive: false)
.firstMatch(match.group(1)!);
if (language != null) {
text = language.group(1)! + text;
}
return text;
}
static String _parseBlockquoteContent(_ConvertOpts opts, Element node) {
final msg = _walkChildNodes(opts, node);
return msg.split('\n').map((s) => '> $s').join('\n') + '\n';
}
static String _parseSpanContent(_ConvertOpts opts, Element node) {
final content = _walkChildNodes(opts, node);
if (node.attributes['data-mx-spoiler'] is String) {
var spoiler = '█' * content.length;
final reason = node.attributes['data-mx-spoiler'];
if (reason != '') {
spoiler = '($reason) $spoiler';
}
return spoiler;
}
return content;
}
static String _parseUlContent(_ConvertOpts opts, Element node) {
opts.listDepth++;
final entries = _listChildNodes(opts, node, {'li'});
opts.listDepth--;
final bulletPoint =
_listBulletPoints[opts.listDepth % _listBulletPoints.length];
return entries
.map((s) =>
(' ' * opts.listDepth) +
bulletPoint +
' ' +
s.replaceAll('\n', '\n' + (' ' * opts.listDepth) + ' '))
.join('\n');
}
static String _parseOlContent(_ConvertOpts opts, Element node) {
opts.listDepth++;
final entries = _listChildNodes(opts, node, {'li'});
opts.listDepth--;
final startStr = node.attributes['start'];
final start = (startStr is String &&
RegExp(r'^[0-9]+$', multiLine: false).hasMatch(startStr))
? int.parse(startStr)
: 1;
return entries
.mapIndexed((index, s) =>
(' ' * opts.listDepth) +
'${start + index}. ' +
s.replaceAll('\n', '\n' + (' ' * opts.listDepth) + ' '))
.join('\n');
}
static const _listBulletPoints = <String>['●', '○', '■', '‣'];
static List<String> _listChildNodes(_ConvertOpts opts, Element node,
[Iterable<String>? types]) {
final replies = <String>[];
for (final child in node.nodes) {
if (types != null &&
types.isNotEmpty &&
((child is Text) ||
((child is Element) &&
!types.contains(child.localName!.toLowerCase())))) {
continue;
}
replies.add(_walkNode(opts, child));
}
return replies;
}
static const _blockTags = <String>{
'blockquote',
'ul',
'ol',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'pre',
};
static String _walkChildNodes(_ConvertOpts opts, Node node) {
var reply = '';
var lastTag = '';
for (final child in node.nodes) {
final thisTag = child is Element ? child.localName!.toLowerCase() : '';
if (thisTag == 'p' && lastTag == 'p') {
reply += '\n\n';
} else if (_blockTags.contains(thisTag) &&
reply.isNotEmpty &&
reply[reply.length - 1] != '\n') {
reply += '\n';
}
reply += _walkNode(opts, child);
if (thisTag.isNotEmpty) {
lastTag = thisTag;
}
}
return reply;
}
static String _walkNode(_ConvertOpts opts, Node node) {
if (node is Text) {
// ignore \n between single nodes
return node.text == '\n' ? '' : node.text;
} else if (node is Element) {
final tag = node.localName!.toLowerCase();
switch (tag) {
case 'em':
case 'i':
return '*${_walkChildNodes(opts, node)}*';
case 'strong':
case 'b':
return '**${_walkChildNodes(opts, node)}**';
case 'u':
case 'ins':
return '__${_walkChildNodes(opts, node)}__';
case 'del':
case 'strike':
case 's':
return '~~${_walkChildNodes(opts, node)}~~';
case 'code':
return '`${node.text}`';
case 'pre':
return '```${_parsePreContent(opts, node)}```\n';
case 'a':
final href = node.attributes['href'] ?? '';
final content = _walkChildNodes(opts, node);
if (href.toLowerCase().startsWith('https://matrix.to/#/') ||
href.toLowerCase().startsWith('matrix:')) {
return content;
}
return '🔗$content';
case 'img':
return node.attributes['alt'] ??
node.attributes['title'] ??
node.attributes['src'] ??
'';
case 'br':
return '\n';
case 'blockquote':
return _parseBlockquoteContent(opts, node);
case 'ul':
return _parseUlContent(opts, node);
case 'ol':
return _parseOlContent(opts, node);
case 'mx-reply':
return '';
case 'hr':
return '\n----------\n';
case 'h1':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6':
final mark = '#' * int.parse(tag[1]);
return '$mark ${_walkChildNodes(opts, node)}\n';
case 'span':
return _parseSpanContent(opts, node);
default:
return _walkChildNodes(opts, node);
}
} else {
return _walkChildNodes(opts, node);
}
}
}
class _ConvertOpts {
int listDepth = 0;
}

View file

@ -0,0 +1,115 @@
/*
* Famedly Matrix SDK
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:http/http.dart' as http;
import '../../matrix.dart';
/// Stream.timeout fails if no progress is made in timeLimit.
/// In contrast, streamTotalTimeout fails if the stream isn't completed
/// until timeoutFuture.
Stream<T> streamTotalTimeout<T>(
Stream<T> stream, Future<Never> timeoutFuture) async* {
final si = StreamIterator(stream);
while (await Future.any([si.moveNext(), timeoutFuture])) {
yield si.current;
}
}
http.StreamedResponse replaceStream(
http.StreamedResponse base, Stream<List<int>> stream) =>
http.StreamedResponse(
http.ByteStream(stream),
base.statusCode,
contentLength: base.contentLength,
request: base.request,
headers: base.headers,
isRedirect: base.isRedirect,
persistentConnection: base.persistentConnection,
reasonPhrase: base.reasonPhrase,
);
/// Http Client that enforces a timeout on requests.
/// Timeout calculation is done in a subclass.
abstract class TimeoutHttpClient extends http.BaseClient {
TimeoutHttpClient(this.inner);
http.Client inner;
Duration get timeout;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final timeoutFuture = Completer<Never>().future.timeout(timeout);
final response = await Future.any([inner.send(request), timeoutFuture]);
return replaceStream(
response, streamTotalTimeout(response.stream, timeoutFuture));
}
}
class FixedTimeoutHttpClient extends TimeoutHttpClient {
FixedTimeoutHttpClient(http.Client inner, this.timeout) : super(inner);
@override
Duration timeout;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) =>
super.send(request);
}
class VariableTimeoutHttpClient extends TimeoutHttpClient {
/// Matrix synchronisation is done with https long polling. This needs a
/// timeout which is usually 30 seconds.
int syncTimeoutSec;
int _timeoutFactor = 1;
@override
Duration get timeout =>
Duration(seconds: _timeoutFactor * syncTimeoutSec + 5);
VariableTimeoutHttpClient(http.Client inner, [this.syncTimeoutSec = 30])
: super(inner);
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
try {
final response = await super.send(request);
return replaceStream(response, (() async* {
try {
await for (final chunk in response.stream) {
yield chunk;
}
_timeoutFactor = 1;
} on TimeoutException catch (e, s) {
_timeoutFactor *= 2;
throw MatrixConnectionException(e, s);
} catch (e, s) {
throw MatrixConnectionException(e, s);
}
})());
} on TimeoutException catch (e, s) {
_timeoutFactor *= 2;
throw MatrixConnectionException(e, s);
} catch (e, s) {
throw MatrixConnectionException(e, s);
}
}
}

View file

@ -0,0 +1,95 @@
/*
* Famedly Matrix SDK
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/
import 'package:slugify/slugify.dart';
import 'package:matrix_api_lite/matrix_api_lite.dart';
import '../room.dart';
extension ImagePackRoomExtension on Room {
/// Get all the active image packs for the specified [usage], mapped by their slug
Map<String, ImagePackContent> getImagePacks([ImagePackUsage? usage]) {
final allMxcs = <Uri>{}; // used for easy deduplication
final packs = <String, ImagePackContent>{};
final addImagePack = (BasicEvent? event, {Room? room, String? slug}) {
if (event == null) return;
final imagePack = event.parsedImagePackContent;
final finalSlug = slugify(slug ?? 'pack');
for (final entry in imagePack.images.entries) {
final image = entry.value;
if (allMxcs.contains(image.url)) {
continue;
}
final imageUsage = image.usage ?? imagePack.pack.usage;
if (usage != null &&
imageUsage != null &&
!imageUsage.contains(usage)) {
continue;
}
packs
.putIfAbsent(
finalSlug,
() => ImagePackContent.fromJson({})
..pack.displayName = imagePack.pack.displayName ??
room?.displayname ??
finalSlug
..pack.avatarUrl = imagePack.pack.avatarUrl ?? room?.avatar
..pack.attribution = imagePack.pack.attribution)
.images[entry.key] = image;
allMxcs.add(image.url);
}
};
// first we add the user image pack
addImagePack(client.accountData['im.ponies.user_emotes'], slug: 'user');
// next we add all the external image packs
final packRooms = client.accountData['im.ponies.emote_rooms'];
if (packRooms != null && packRooms.content['rooms'] is Map) {
for (final roomEntry in packRooms.content['rooms'].entries) {
final roomId = roomEntry.key;
final room = client.getRoomById(roomId);
if (room != null && roomEntry.value is Map) {
for (final stateKeyEntry in roomEntry.value.entries) {
final stateKey = stateKeyEntry.key;
final fallbackSlug =
'${room.displayname}-${stateKey.isNotEmpty ? '$stateKey-' : ''}${room.id}';
addImagePack(room.getState('im.ponies.room_emotes', stateKey),
room: room, slug: fallbackSlug);
}
}
}
}
// finally we add all of this rooms state
final allRoomEmotes = states['im.ponies.room_emotes'];
if (allRoomEmotes != null) {
for (final entry in allRoomEmotes.entries) {
addImagePack(entry.value,
room: this,
slug: (entry.value.stateKey?.isNotEmpty == true)
? entry.value.stateKey
: 'room');
}
}
return packs;
}
/// Get a flat view of all the image packs of a specified [usage], that is a map of all
/// slugs to a map of the image code to their mxc url
Map<String, Map<String, String>> getImagePacksFlat([ImagePackUsage? usage]) =>
getImagePacks(usage).map((k, v) =>
MapEntry(k, v.images.map((k, v) => MapEntry(k, v.url.toString()))));
}

View file

@ -0,0 +1,33 @@
/*
* Famedly Matrix SDK
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/
extension MapCopyExtension on Map<String, dynamic> {
/// Deep-copies a given json map
Map<String, dynamic> copy() {
final copy = Map<String, dynamic>.from(this);
for (final entry in copy.entries) {
if (entry.value is Map<String, dynamic>) {
copy[entry.key] = (entry.value as Map<String, dynamic>).copy();
}
if (entry.value is List) {
copy[entry.key] = List.from(entry.value);
}
}
return copy;
}
}

256
lib/src/utils/markdown.dart Normal file
View file

@ -0,0 +1,256 @@
/*
* Famedly Matrix SDK
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/
import 'package:markdown/markdown.dart';
import 'dart:convert';
const htmlAttrEscape = HtmlEscape(HtmlEscapeMode.attribute);
class LinebreakSyntax extends InlineSyntax {
LinebreakSyntax() : super(r'\n');
@override
bool onMatch(InlineParser parser, Match match) {
parser.addNode(Element.empty('br'));
return true;
}
}
class SpoilerSyntax extends TagSyntax {
SpoilerSyntax() : super(r'\|\|', requiresDelimiterRun: true);
@override
Node close(InlineParser parser, Delimiter opener, Delimiter closer,
{required List<Node> Function() getChildren}) {
final children = getChildren();
final newChildren = <Node>[];
var searchingForReason = true;
var reason = '';
for (final child in children) {
// If we already found a reason, let's just use our child nodes as-is
if (!searchingForReason) {
newChildren.add(child);
continue;
}
if (child is Text) {
final ix = child.text.indexOf('|');
if (ix > 0) {
reason += child.text.substring(0, ix);
newChildren.add(Text(child.text.substring(ix + 1)));
searchingForReason = false;
} else {
reason += child.text;
}
} else {
// if we don't have a text node as reason we just want to cancel this whole thing
break;
}
}
// if we were still searching for a reason that means there was none - use the original children!
final element =
Element('span', searchingForReason ? children : newChildren);
element.attributes['data-mx-spoiler'] =
searchingForReason ? '' : htmlAttrEscape.convert(reason);
return element;
}
}
class EmoteSyntax extends InlineSyntax {
final Map<String, Map<String, String>> Function()? getEmotePacks;
Map<String, Map<String, String>>? emotePacks;
EmoteSyntax(this.getEmotePacks) : super(r':(?:([-\w]+)~)?([-\w]+):');
@override
bool onMatch(InlineParser parser, Match match) {
final emotePacks = this.emotePacks ??= getEmotePacks?.call() ?? {};
final pack = match[1] ?? '';
final emote = match[2];
String? mxc;
if (pack.isEmpty) {
// search all packs
for (final emotePack in emotePacks.values) {
mxc = emotePack[emote];
if (mxc != null) {
break;
}
}
} else {
mxc = emotePacks[pack]?[emote];
}
if (mxc == null) {
// emote not found. Insert the whole thing as plain text
parser.addNode(Text(match[0]!));
return true;
}
final element = Element.empty('img');
element.attributes['data-mx-emoticon'] = '';
element.attributes['src'] = htmlAttrEscape.convert(mxc);
element.attributes['alt'] = htmlAttrEscape.convert(':$emote:');
element.attributes['title'] = htmlAttrEscape.convert(':$emote:');
element.attributes['height'] = '32';
element.attributes['vertical-align'] = 'middle';
parser.addNode(element);
return true;
}
}
class InlineLatexSyntax extends TagSyntax {
InlineLatexSyntax() : super(r'\$([^\s$]([^\$]*[^\s$])?)\$');
@override
bool onMatch(InlineParser parser, Match match) {
final element =
Element('span', [Element.text('code', htmlEscape.convert(match[1]!))]);
element.attributes['data-mx-maths'] = htmlAttrEscape.convert(match[1]!);
parser.addNode(element);
return true;
}
}
// We also want to allow single-lines of like "$$latex$$"
class BlockLatexSyntax extends BlockSyntax {
@override
RegExp get pattern => RegExp(r'^[ ]{0,3}\$\$(.*)$');
final endPattern = RegExp(r'^(.*)\$\$\s*$');
@override
List<String> parseChildLines(BlockParser parser) {
final childLines = <String>[];
var first = true;
while (!parser.isDone) {
final match = endPattern.firstMatch(parser.current);
if (match == null || (first && match[1]!.trim().isEmpty)) {
childLines.add(parser.current);
parser.advance();
} else {
childLines.add(match[1]!);
parser.advance();
break;
}
first = false;
}
return childLines;
}
@override
Node parse(BlockParser parser) {
final childLines = parseChildLines(parser);
// we use .substring(2) as childLines will *always* contain the first two '$$'
final latex = childLines.join('\n').trim().substring(2).trim();
final element = Element('div', [
Element('pre', [Element.text('code', htmlEscape.convert(latex))])
]);
element.attributes['data-mx-maths'] = htmlAttrEscape.convert(latex);
return element;
}
}
class PillSyntax extends InlineSyntax {
PillSyntax()
: super(
r'([@#!][^\s:]*:(?:[^\s]+\.\w+|[\d\.]+|\[[a-fA-F0-9:]+\])(?::\d+)?)');
@override
bool onMatch(InlineParser parser, Match match) {
if (match.start > 0 &&
!RegExp(r'[\s.!?:;\(]').hasMatch(match.input[match.start - 1])) {
parser.addNode(Text(match[0]!));
return true;
}
final identifier = match[1]!;
final element = Element.text('a', htmlEscape.convert(identifier));
element.attributes['href'] =
htmlAttrEscape.convert('https://matrix.to/#/$identifier');
parser.addNode(element);
return true;
}
}
class MentionSyntax extends InlineSyntax {
final String? Function(String)? getMention;
MentionSyntax(this.getMention) : super(r'(@(?:\[[^\]:]+\]|\w+)(?:#\w+)?)');
@override
bool onMatch(InlineParser parser, Match match) {
final mention = getMention?.call(match[1]!);
if ((match.start > 0 &&
!RegExp(r'[\s.!?:;\(]').hasMatch(match.input[match.start - 1])) ||
mention == null) {
parser.addNode(Text(match[0]!));
return true;
}
final element = Element.text('a', htmlEscape.convert(match[1]!));
element.attributes['href'] =
htmlAttrEscape.convert('https://matrix.to/#/$mention');
parser.addNode(element);
return true;
}
}
String markdown(
String text, {
Map<String, Map<String, String>> Function()? getEmotePacks,
String? Function(String)? getMention,
}) {
var ret = markdownToHtml(
text,
extensionSet: ExtensionSet.commonMark,
blockSyntaxes: [
BlockLatexSyntax(),
],
inlineSyntaxes: [
StrikethroughSyntax(),
LinebreakSyntax(),
SpoilerSyntax(),
EmoteSyntax(getEmotePacks),
PillSyntax(),
MentionSyntax(getMention),
InlineLatexSyntax(),
],
);
var stripPTags = '<p>'.allMatches(ret).length <= 1;
if (stripPTags) {
const otherBlockTags = {
'table',
'pre',
'ol',
'ul',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'div',
};
for (final tag in otherBlockTags) {
// we check for the close tag as the opening one might have attributes
if (ret.contains('</$tag>')) {
stripPTags = false;
break;
}
}
}
if (stripPTags) {
ret = ret.replaceAll('<p>', '').replaceAll('</p>', '');
}
return ret.trim().replaceAll(RegExp(r'(<br />)+$'), '');
}

View file

@ -0,0 +1,34 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import 'package:matrix_api_lite/src/utils/try_get_map_extension.dart';
mixin EventType {
static const String markedUnread = 'com.famedly.marked_unread';
}
class MarkedUnread {
final bool unread;
const MarkedUnread(this.unread);
MarkedUnread.fromJson(Map<String, dynamic> json)
: unread = json.tryGet<bool>('unread') ?? false;
Map<String, dynamic> toJson() => {'unread': unread};
}

View file

@ -0,0 +1,290 @@
/*
* Famedly Matrix SDK
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/
/// Workaround until [File] in dart:io and dart:html is unified
import 'dart:async';
import 'dart:typed_data';
import 'package:blurhash_dart/blurhash_dart.dart';
import 'package:image/image.dart';
import 'package:mime/mime.dart';
import '../../matrix.dart';
class MatrixFile {
final Uint8List bytes;
final String name;
final String mimeType;
/// Encrypts this file and returns the
/// encryption information as an [EncryptedFile].
Future<EncryptedFile> encrypt() async {
return await encryptFile(bytes);
}
MatrixFile({required this.bytes, required String name, String? mimeType})
: mimeType = mimeType ??
lookupMimeType(name, headerBytes: bytes) ??
'application/octet-stream',
name = name.split('/').last.toLowerCase();
int get size => bytes.length;
String get msgType {
if (mimeType.toLowerCase().startsWith('image/')) {
return MessageTypes.Image;
}
if (mimeType.toLowerCase().startsWith('video/')) {
return MessageTypes.Video;
}
if (mimeType.toLowerCase().startsWith('audio/')) {
return MessageTypes.Audio;
}
return MessageTypes.File;
}
Map<String, dynamic> get info => ({
'mimetype': mimeType,
'size': size,
});
}
class MatrixImageFile extends MatrixFile {
MatrixImageFile({
required Uint8List bytes,
required String name,
String? mimeType,
this.width,
this.height,
this.blurhash,
}) : super(bytes: bytes, name: name, mimeType: mimeType);
/// Creates a new image file and calculates the width, height and blurhash.
static Future<MatrixImageFile> create(
{required Uint8List bytes,
required String name,
String? mimeType,
Future<T> Function<T, U>(FutureOr<T> Function(U arg) function, U arg)?
compute}) async {
final metaData = compute != null
? await compute(_calcMetadata, bytes)
: _calcMetadata(bytes);
return MatrixImageFile(
bytes: metaData?.bytes ?? bytes,
name: name,
mimeType: mimeType,
width: metaData?.width,
height: metaData?.height,
blurhash: metaData?.blurhash,
);
}
/// builds a [MatrixImageFile] and shrinks it in order to reduce traffic
///
/// in case shrinking does not work (e.g. for unsupported MIME types), the
/// initial image is simply preserved
static Future<MatrixImageFile> shrink(
{required Uint8List bytes,
required String name,
int maxDimension = 1600,
String? mimeType,
Future<T> Function<T, U>(FutureOr<T> Function(U arg) function, U arg)?
compute}) async {
final arguments = _ResizeArguments(
bytes: bytes,
maxDimension: maxDimension,
fileName: name,
calcBlurhash: true,
);
final resizedData = compute != null
? await compute(_resize, arguments)
: _resize(arguments);
if (resizedData == null) {
return MatrixImageFile(bytes: bytes, name: name, mimeType: mimeType);
}
final thumbnailFile = MatrixImageFile(
bytes: resizedData.bytes,
name: name,
mimeType: mimeType,
width: resizedData.width,
height: resizedData.height,
blurhash: resizedData.blurhash,
);
return thumbnailFile;
}
/// returns the width of the image
final int? width;
/// returns the height of the image
final int? height;
/// generates the blur hash for the image
final String? blurhash;
@override
String get msgType => 'm.image';
@override
Map<String, dynamic> get info => ({
...super.info,
if (width != null) 'w': width,
if (height != null) 'h': height,
if (blurhash != null) 'xyz.amorgan.blurhash': blurhash,
});
/// computes a thumbnail for the image
Future<MatrixImageFile?> generateThumbnail(
{int dimension = Client.defaultThumbnailSize,
Future<T> Function<T, U>(FutureOr<T> Function(U arg) function, U arg)?
compute}) async {
final thumbnailFile = await shrink(
bytes: bytes,
name: name,
mimeType: mimeType,
compute: compute,
maxDimension: dimension,
);
// the thumbnail should rather return null than the unshrinked image
if ((thumbnailFile.width ?? 0) > dimension ||
(thumbnailFile.height ?? 0) > dimension) {
return null;
}
return thumbnailFile;
}
static _ResizedResponse? _calcMetadata(Uint8List bytes) {
final image = decodeImage(bytes);
if (image == null) return null;
return _ResizedResponse(
bytes: bytes,
width: image.width,
height: image.height,
blurhash: BlurHash.encode(
image,
numCompX: 4,
numCompY: 3,
).hash,
);
}
static _ResizedResponse? _resize(_ResizeArguments arguments) {
final image = decodeImage(arguments.bytes);
final resized = copyResize(image!,
height: image.height > image.width ? arguments.maxDimension : null,
width: image.width >= image.height ? arguments.maxDimension : null);
final encoded = encodeNamedImage(resized, arguments.fileName);
if (encoded == null) return null;
final bytes = Uint8List.fromList(encoded);
return _ResizedResponse(
bytes: bytes,
width: resized.width,
height: resized.height,
blurhash: arguments.calcBlurhash
? BlurHash.encode(
resized,
numCompX: 4,
numCompY: 3,
).hash
: null,
);
}
}
class _ResizedResponse {
final Uint8List bytes;
final int width;
final int height;
final String? blurhash;
const _ResizedResponse({
required this.bytes,
required this.width,
required this.height,
this.blurhash,
});
}
class _ResizeArguments {
final Uint8List bytes;
final int maxDimension;
final String fileName;
final bool calcBlurhash;
const _ResizeArguments({
required this.bytes,
required this.maxDimension,
required this.fileName,
required this.calcBlurhash,
});
}
class MatrixVideoFile extends MatrixFile {
final int? width;
final int? height;
final int? duration;
MatrixVideoFile(
{required Uint8List bytes,
required String name,
String? mimeType,
this.width,
this.height,
this.duration})
: super(bytes: bytes, name: name, mimeType: mimeType);
@override
String get msgType => 'm.video';
@override
Map<String, dynamic> get info => ({
...super.info,
if (width != null) 'w': width,
if (height != null) 'h': height,
if (duration != null) 'duration': duration,
});
}
class MatrixAudioFile extends MatrixFile {
final int? duration;
MatrixAudioFile(
{required Uint8List bytes,
required String name,
String? mimeType,
this.duration})
: super(bytes: bytes, name: name, mimeType: mimeType);
@override
String get msgType => 'm.audio';
@override
Map<String, dynamic> get info => ({
...super.info,
if (duration != null) 'duration': duration,
});
}
extension ToMatrixFile on EncryptedFile {
MatrixFile toMatrixFile() {
return MatrixFile(
bytes: data, name: 'crypt', mimeType: 'application/octet-stream');
}
}

View file

@ -0,0 +1,136 @@
/*
* Famedly Matrix SDK
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/
const Set<String> validSigils = {'@', '!', '#', '\$', '+'};
const int maxLength = 255;
extension MatrixIdExtension on String {
List<String> _getParts() {
final s = substring(1);
final ix = s.indexOf(':');
if (ix == -1) {
return [substring(1)];
}
return [s.substring(0, ix), s.substring(ix + 1)];
}
bool get isValidMatrixId {
if (isEmpty) return false;
if (length > maxLength) return false;
if (!validSigils.contains(substring(0, 1))) {
return false;
}
// event IDs do not have to have a domain
if (substring(0, 1) == '\$') {
return true;
}
// all other matrix IDs have to have a domain
final parts = _getParts();
// the localpart can be an empty string, e.g. for aliases
if (parts.length != 2 || parts[1].isEmpty) {
return false;
}
return true;
}
String? get sigil => isValidMatrixId ? substring(0, 1) : null;
String? get localpart => isValidMatrixId ? _getParts().first : null;
String? get domain => isValidMatrixId ? _getParts().last : null;
bool equals(String? other) => toLowerCase() == other?.toLowerCase();
/// Parse a matrix identifier string into a Uri. Primary and secondary identifiers
/// are stored in pathSegments. The query string is stored as such.
Uri? _parseIdentifierIntoUri() {
const matrixUriPrefix = 'matrix:';
const matrixToPrefix = 'https://matrix.to/#/';
if (toLowerCase().startsWith(matrixUriPrefix)) {
final uri = Uri.tryParse(this);
if (uri == null) return null;
final pathSegments = uri.pathSegments;
final identifiers = <String>[];
for (var i = 0; i < pathSegments.length - 1; i += 2) {
final thisSigil = {
'u': '@',
'roomid': '!',
'r': '#',
'e': '\$',
}[pathSegments[i].toLowerCase()];
if (thisSigil == null) {
break;
}
identifiers.add(thisSigil + pathSegments[i + 1]);
}
return uri.replace(pathSegments: identifiers);
} else if (toLowerCase().startsWith(matrixToPrefix)) {
return Uri.tryParse('//' +
substring(matrixToPrefix.length - 1)
.replaceAllMapped(
RegExp(r'(?<=/)[#!@+][^:]*:|(\?.*$)'),
(m) => m[0]!.replaceAllMapped(
RegExp(m.group(1) != null ? '' : '[/?]'),
(m) => Uri.encodeComponent(m.group(0)!)))
.replaceAll('#', '%23'));
} else {
return Uri(
pathSegments: RegExp(r'/((?:[#!@+][^:]*:)?[^/?]*)(?:\?.*$)?')
.allMatches('/$this')
.map((m) => m[1]!),
query: RegExp(r'(?:/(?:[#!@+][^:]*:)?[^/?]*)*\?(.*$)')
.firstMatch('/$this')?[1]);
}
}
/// Separate a matrix identifier string into a primary indentifier, a secondary identifier,
/// a query string and already parsed `via` parameters. A matrix identifier string
/// can be an mxid, a matrix.to-url or a matrix-uri.
MatrixIdentifierStringExtensionResults? parseIdentifierIntoParts() {
final uri = _parseIdentifierIntoUri();
if (uri == null) return null;
final primary = uri.pathSegments.isNotEmpty ? uri.pathSegments[0] : null;
if (primary == null || !primary.isValidMatrixId) return null;
final secondary = uri.pathSegments.length > 1 ? uri.pathSegments[1] : null;
if (secondary != null && !secondary.isValidMatrixId) return null;
return MatrixIdentifierStringExtensionResults(
primaryIdentifier: primary,
secondaryIdentifier: secondary,
queryString: uri.query.isNotEmpty ? uri.query : null,
via: (uri.queryParametersAll['via'] ?? []).toSet(),
action: uri.queryParameters['action'],
);
}
}
class MatrixIdentifierStringExtensionResults {
final String primaryIdentifier;
final String? secondaryIdentifier;
final String? queryString;
final Set<String> via;
final String? action;
MatrixIdentifierStringExtensionResults(
{required this.primaryIdentifier,
this.secondaryIdentifier,
this.queryString,
this.via = const {},
this.action});
}

View file

@ -0,0 +1,179 @@
/*
* Famedly Matrix SDK
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/
import '../room.dart';
abstract class MatrixLocalizations {
const MatrixLocalizations();
String get emptyChat;
String get invitedUsersOnly;
String get fromTheInvitation;
String get fromJoining;
String get visibleForAllParticipants;
String get visibleForEveryone;
String get guestsCanJoin;
String get guestsAreForbidden;
String get anyoneCanJoin;
String get needPantalaimonWarning;
String get channelCorruptedDecryptError;
String get encryptionNotEnabled;
String get unknownEncryptionAlgorithm;
String get noPermission;
String get you;
String get roomHasBeenUpgraded;
String groupWith(String displayname);
String removedBy(String calcDisplayname);
String sentASticker(String senderName);
String redactedAnEvent(String senderName);
String changedTheRoomAliases(String senderName);
String changedTheRoomInvitationLink(String senderName);
String createdTheChat(String senderName);
String changedTheJoinRules(String senderName);
String changedTheJoinRulesTo(String senderName, String localizedString);
String acceptedTheInvitation(String targetName);
String rejectedTheInvitation(String targetName);
String hasWithdrawnTheInvitationFor(String senderName, String targetName);
String joinedTheChat(String targetName);
String kickedAndBanned(String senderName, String targetName);
String kicked(String senderName, String targetName);
String userLeftTheChat(String targetName);
String bannedUser(String senderName, String targetName);
String unbannedUser(String senderName, String targetName);
String invitedUser(String senderName, String targetName);
String changedTheProfileAvatar(String targetName);
String changedTheDisplaynameTo(String targetName, String newDisplayname);
String changedTheChatPermissions(String senderName);
String changedTheChatNameTo(String senderName, String content);
String changedTheChatDescriptionTo(String senderName, String content);
String changedTheChatAvatar(String senderName);
String changedTheGuestAccessRules(String senderName);
String changedTheGuestAccessRulesTo(
String senderName, String localizedString);
String changedTheHistoryVisibility(String senderName);
String changedTheHistoryVisibilityTo(
String senderName, String localizedString);
String activatedEndToEndEncryption(String senderName);
String sentAPicture(String senderName);
String sentAFile(String senderName);
String sentAnAudio(String senderName);
String sentAVideo(String senderName);
String sentReaction(String senderName, String reactionKey);
String sharedTheLocation(String senderName);
String couldNotDecryptMessage(String errorText);
String unknownEvent(String typeKey);
String startedACall(String senderName);
String endedTheCall(String senderName);
String answeredTheCall(String senderName);
String sentCallInformations(String senderName);
}
extension HistoryVisibilityDisplayString on HistoryVisibility {
String getLocalizedString(MatrixLocalizations i18n) {
switch (this) {
case HistoryVisibility.invited:
return i18n.fromTheInvitation;
case HistoryVisibility.joined:
return i18n.fromJoining;
case HistoryVisibility.shared:
return i18n.visibleForAllParticipants;
case HistoryVisibility.worldReadable:
return i18n.visibleForEveryone;
}
}
}
extension GuestAccessDisplayString on GuestAccess {
String getLocalizedString(MatrixLocalizations i18n) {
switch (this) {
case GuestAccess.canJoin:
return i18n.guestsCanJoin;
case GuestAccess.forbidden:
return i18n.guestsAreForbidden;
}
}
}
extension JoinRulesDisplayString on JoinRules {
String getLocalizedString(MatrixLocalizations i18n) {
switch (this) {
case JoinRules.public:
return i18n.anyoneCanJoin;
case JoinRules.invite:
return i18n.invitedUsersOnly;
default:
return toString().replaceAll('JoinRules.', '');
}
}
}

View file

@ -0,0 +1,68 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import 'dart:async';
/// Lock management class. It allows to lock and unlock multiple keys at once. The keys have
/// the type [T]
class MultiLock<T> {
final Map<T, Completer<void>> _completers = {};
/// Set a number of [keys] locks, awaiting them to be released previously.
Future<void> lock(Iterable<T> keys) async {
// An iterable might have duplicate entries. A set is guaranteed not to, and we need
// unique entries, as else a lot of things might go bad.
final uniqueKeys = keys.toSet();
// we want to make sure that there are no existing completers for any of the locks
// we are trying to set. So, we await all the completers until they are all gone.
// We can't just assume they are all gone after one go, due to rare race conditions
// which could then result in a deadlock.
while (_completers.keys.any((k) => uniqueKeys.contains(k))) {
// Here we try to build all the futures to wait for single completers and then await
// them at the same time, in parallel
final futures = <Future<void>>[];
for (final key in uniqueKeys) {
if (_completers[key] != null) {
futures.add(() async {
while (_completers[key] != null) {
await _completers[key]!.future;
}
}());
}
}
await Future.wait(futures);
}
// And finally set all the completers
for (final key in uniqueKeys) {
_completers[key] = Completer<void>();
}
}
/// Unlock all [keys] locks. Typically these should be the same keys as called
/// in `.lock(keys)``
void unlock(Iterable<T> keys) {
final uniqueKeys = keys.toSet();
// we just have to simply unlock all the completers
for (final key in uniqueKeys) {
if (_completers[key] != null) {
final completer = _completers[key]!;
_completers.remove(key);
completer.complete();
}
}
}
}

View file

@ -0,0 +1,50 @@
/*
* Famedly Matrix SDK
* Copyright (C) 2019, 2020 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 <https://www.gnu.org/licenses/>.
*/
import 'dart:convert';
class QueuedToDeviceEvent {
final int id;
final String type;
final String txnId;
final Map<String, dynamic> content;
QueuedToDeviceEvent({
required this.id,
required this.type,
required this.txnId,
required this.content,
});
factory QueuedToDeviceEvent.fromJson(Map<String, dynamic> json) =>
QueuedToDeviceEvent(
id: json['id'],
type: json['type'],
txnId: json['txn_id'],
// Temporary fix to stay compatible to Moor AND a key value store
content: json['content'] is String
? jsonDecode(json['content'])
: json['content'],
);
Map<String, dynamic> toJson() => {
'id': id,
'type': type,
'txn_id': txnId,
'content': content,
};
}

View file

@ -0,0 +1,33 @@
/*
* Famedly Matrix SDK
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/
import '../user.dart';
/// Represents a receipt.
/// This [user] has read an event at the given [time].
class Receipt {
final User user;
final DateTime time;
const Receipt(this.user, this.time);
@override
bool operator ==(dynamic other) => (other is Receipt &&
other.user == user &&
other.time.microsecondsSinceEpoch == time.microsecondsSinceEpoch);
}

View file

@ -0,0 +1,43 @@
/*
* Famedly Matrix SDK
* Copyright (C) 2019, 2020 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 <https://www.gnu.org/licenses/>.
*/
import 'package:matrix/matrix.dart';
/// Calculates some benchmarks for this function. Give it a [name] and a [func]
/// to call and it will calculate the needed milliseconds. Give it an optional
/// [itemCount] to let it also calculate the needed milliseconds per item.
Future<T> runBenchmarked<T>(
String name,
Future<T> Function() func, [
int? itemCount,
]) async {
if (Logs().level.index < Level.debug.index) {
return func();
}
final start = DateTime.now();
final result = await func();
final milliseconds =
DateTime.now().millisecondsSinceEpoch - start.millisecondsSinceEpoch;
var message = 'Benchmark: $name -> $milliseconds ms';
if (itemCount != null) {
message +=
' ($itemCount items, ${itemCount > 0 ? milliseconds / itemCount : milliseconds} ms/item)';
}
Logs().d(message);
return result;
}

View file

@ -0,0 +1,32 @@
/*
* Famedly Matrix SDK
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/
import 'dart:async';
import '../../matrix.dart';
Future<T?> runInRoot<T>(FutureOr<T> Function() fn) async {
return await Zone.root.run(() async {
try {
return await fn();
} catch (e, s) {
Logs().e('Error thrown in root zone', e, s);
}
return null;
});
}

View file

@ -0,0 +1,47 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import 'package:matrix_api_lite/matrix_api_lite.dart';
import '../event.dart';
class SpaceChild {
final String? roomId;
final List<String>? via;
final String order;
final bool? suggested;
SpaceChild.fromState(Event state)
: assert(state.type == EventTypes.spaceChild),
roomId = state.stateKey,
via = state.content.tryGetList<String>('via'),
order = state.content.tryGet<String>('order') ?? '',
suggested = state.content.tryGet<bool>('suggested');
}
class SpaceParent {
final String? roomId;
final List<String>? via;
final bool? canonical;
SpaceParent.fromState(Event state)
: assert(state.type == EventTypes.spaceParent),
roomId = state.stateKey,
via = state.content.tryGetList<String>('via'),
canonical = state.content.tryGet<bool>('canonical');
}

View file

@ -0,0 +1,44 @@
/*
* Famedly Matrix SDK
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/
import '../../matrix.dart';
/// This extension adds easy-to-use filters for the sync update, meant to be used on the `client.onSync` stream, e.g.
/// `client.onSync.stream.where((s) => s.hasRoomUpdate)`. Multiple filters can easily be
/// combind with boolean logic: `client.onSync.stream.where((s) => s.hasRoomUpdate || s.hasPresenceUpdate)`
extension SyncUpdateFilters on SyncUpdate {
/// Returns true if this sync updat has a room update
/// That means there is account data, if there is a room in one of the `join`, `leave` or `invite` blocks of the sync or if there is a to_device event.
bool get hasRoomUpdate {
// if we have an account data change we need to re-render, as `m.direct` might have changed
if (accountData?.isNotEmpty ?? false) {
return true;
}
// check for a to_device event
if (toDevice?.isNotEmpty ?? false) {
return true;
}
// return if there are rooms to update
return (rooms?.join?.isNotEmpty ?? false) ||
(rooms?.invite?.isNotEmpty ?? false) ||
(rooms?.leave?.isNotEmpty ?? false);
}
/// Returns if this sync update has presence updates
bool get hasPresenceUpdate => presence?.isNotEmpty ?? false;
}

View file

@ -0,0 +1,41 @@
import 'package:matrix/matrix.dart';
extension SyncUpdateItemCount on SyncUpdate {
int get itemCount {
var count = 0;
count += accountData?.length ?? 0;
count += deviceLists?.changed?.length ?? 0;
count += deviceLists?.left?.length ?? 0;
count += toDevice?.length ?? 0;
count += presence?.length ?? 0;
count += _joinRoomsItemCount;
count += _inviteRoomsItemCount;
count += _leaveRoomsItemCount;
return count;
}
int get _joinRoomsItemCount =>
rooms?.join?.values.fold<int>(
0,
(prev, room) =>
prev +
(room.accountData?.length ?? 0) +
(room.state?.length ?? 0) +
(room.timeline?.events?.length ?? 0)) ??
0;
int get _inviteRoomsItemCount =>
rooms?.invite?.values.fold<int>(
0, (prev, room) => prev + (room.inviteState?.length ?? 0)) ??
0;
int get _leaveRoomsItemCount =>
rooms?.leave?.values.fold<int>(
0,
(prev, room) =>
prev +
(room.accountData?.length ?? 0) +
(room.state?.length ?? 0) +
(room.timeline?.events?.length ?? 0)) ??
0;
}

View file

@ -0,0 +1,53 @@
/*
* Famedly Matrix SDK
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/
import '../../matrix.dart';
class ToDeviceEvent extends BasicEventWithSender {
Map<String, dynamic>? encryptedContent;
String get sender => senderId;
set sender(String sender) => senderId = sender;
ToDeviceEvent({
required String sender,
required String type,
required Map<String, dynamic> content,
this.encryptedContent,
}) : super(senderId: sender, type: type, content: content);
factory ToDeviceEvent.fromJson(Map<String, dynamic> json) {
final event = BasicEventWithSender.fromJson(json);
return ToDeviceEvent(
sender: event.senderId, type: event.type, content: event.content);
}
}
class ToDeviceEventDecryptionError extends ToDeviceEvent {
Exception exception;
StackTrace? stackTrace;
ToDeviceEventDecryptionError({
required ToDeviceEvent toDeviceEvent,
required this.exception,
this.stackTrace,
}) : super(
sender: toDeviceEvent.senderId,
content: toDeviceEvent.content,
type: toDeviceEvent.type,
);
}

View file

@ -0,0 +1,120 @@
/*
* Famedly Matrix SDK
* Copyright (C) 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 <https://www.gnu.org/licenses/>.
*/
import '../../matrix.dart';
enum UiaRequestState {
/// The request is done
done,
/// The request has failed
fail,
/// The request is currently loading
loading,
/// The request is waiting for user interaction
waitForUser,
}
/// Wrapper to handle User interactive authentication requests
class UiaRequest<T> {
void Function(UiaRequestState state)? onUpdate;
final Future<T> Function(AuthenticationData? auth) request;
String? session;
UiaRequestState _state = UiaRequestState.loading;
T? result;
Exception? error;
Set<String> nextStages = <String>{};
Map<String, dynamic> params = <String, dynamic>{};
UiaRequestState get state => _state;
set state(UiaRequestState newState) {
if (_state == newState) return;
_state = newState;
onUpdate?.call(newState);
}
UiaRequest({this.onUpdate, required this.request}) {
_run();
}
Future<T?> _run([AuthenticationData? auth]) async {
state = UiaRequestState.loading;
try {
final res = await request(auth);
state = UiaRequestState.done;
result = res;
return res;
} on MatrixException catch (err) {
if (err.session == null) {
error = err;
state = UiaRequestState.fail;
return null;
}
session ??= err.session;
final completed = err.completedAuthenticationFlows;
final flows = err.authenticationFlows ?? <AuthenticationFlow>[];
params = err.authenticationParams ?? <String, dynamic>{};
nextStages = getNextStages(flows, completed);
if (nextStages.isEmpty) {
error = err;
state = UiaRequestState.fail;
return null;
}
return null;
} catch (err) {
error = err is Exception ? err : Exception(err);
state = UiaRequestState.fail;
return null;
} finally {
if (state == UiaRequestState.loading) {
state = UiaRequestState.waitForUser;
}
}
}
Future<T?> completeStage(AuthenticationData auth) => _run(auth);
/// Cancel this uia request for example if the app can not handle this stage.
void cancel([Exception? err]) {
error = err ?? Exception('Request has been canceled');
state = UiaRequestState.fail;
}
Set<String> getNextStages(
List<AuthenticationFlow> flows, List<String> completed) {
final nextStages = <String>{};
for (final flow in flows) {
final stages = flow.stages;
final nextStage = stages[completed.length];
var stagesValid = true;
for (var i = 0; i < completed.length; i++) {
if (stages[i] != completed[i]) {
stagesValid = false;
break;
}
}
if (stagesValid) {
nextStages.add(nextStage);
}
}
return nextStages;
}
}

View file

@ -0,0 +1,63 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import 'dart:core';
import '../client.dart';
extension MxcUriExtension on Uri {
/// Returns a download Link to this content.
Uri getDownloadLink(Client matrix) => isScheme('mxc')
? matrix.homeserver != null
? matrix.homeserver?.resolve(
'_matrix/media/r0/download/$host${hasPort ? ':$port' : ''}$path') ??
Uri()
: Uri()
: this;
/// Returns a scaled thumbnail link to this content with the given `width` and
/// `height`. `method` can be `ThumbnailMethod.crop` or
/// `ThumbnailMethod.scale` and defaults to `ThumbnailMethod.scale`.
/// If `animated` (default false) is set to true, an animated thumbnail is requested
/// as per MSC2705. Thumbnails only animate if the media repository supports that.
Uri getThumbnail(Client matrix,
{num? width,
num? height,
ThumbnailMethod? method = ThumbnailMethod.crop,
bool? animated = false}) {
if (!isScheme('mxc')) return this;
final homeserver = matrix.homeserver;
if (homeserver == null) {
return Uri();
}
return Uri(
scheme: homeserver.scheme,
host: homeserver.host,
path: '/_matrix/media/r0/thumbnail/$host${hasPort ? ':$port' : ''}$path',
port: homeserver.port,
queryParameters: {
if (width != null) 'width': width.round().toString(),
if (height != null) 'height': height.round().toString(),
if (method != null) 'method': method.toString().split('.').last,
if (animated != null) 'animated': animated.toString(),
},
);
}
}
enum ThumbnailMethod { crop, scale }