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

2542
lib/src/client.dart Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,309 @@
/*
* 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:typed_data';
import 'package:matrix/encryption/utils/olm_session.dart';
import 'package:matrix/encryption/utils/outbound_group_session.dart';
import 'package:matrix/encryption/utils/ssss_cache.dart';
import 'package:matrix/encryption/utils/stored_inbound_group_session.dart';
import 'package:matrix/src/utils/queued_to_device_event.dart';
import '../../matrix.dart';
abstract class DatabaseApi {
int get maxFileSize => 1 * 1024 * 1024;
bool get supportsFileStoring => false;
Future<Map<String, dynamic>?> getClient(String name);
Future updateClient(
String homeserverUrl,
String token,
String userId,
String? deviceId,
String? deviceName,
String? prevBatch,
String? olmAccount,
);
Future insertClient(
String name,
String homeserverUrl,
String token,
String userId,
String? deviceId,
String? deviceName,
String? prevBatch,
String? olmAccount,
);
Future<List<Room>> getRoomList(Client client);
Future<Map<String, BasicEvent>> getAccountData();
/// Stores a RoomUpdate object in the database. Must be called inside of
/// [transaction].
Future<void> storeRoomUpdate(
String roomId, SyncRoomUpdate roomUpdate, Client client);
/// Stores an EventUpdate object in the database. Must be called inside of
/// [transaction].
Future<void> storeEventUpdate(EventUpdate eventUpdate, Client client);
Future<Event?> getEventById(String eventId, Room room);
Future<void> forgetRoom(String roomId);
Future<void> clearCache();
Future<void> clear();
Future<User?> getUser(String userId, Room room);
Future<List<User>> getUsers(Room room);
Future<List<Event>> getEventList(
Room room, {
int start = 0,
int limit,
});
Future<Uint8List?> getFile(Uri mxcUri);
Future storeFile(Uri mxcUri, Uint8List bytes, int time);
Future storeSyncFilterId(
String syncFilterId,
);
Future storeAccountData(String type, String content);
Future<Map<String, DeviceKeysList>> getUserDeviceKeys(Client client);
Future<SSSSCache?> getSSSSCache(String type);
Future<OutboundGroupSession?> getOutboundGroupSession(
String roomId,
String userId,
);
Future<List<StoredInboundGroupSession>> getAllInboundGroupSessions();
Future<StoredInboundGroupSession?> getInboundGroupSession(
String roomId,
String sessionId,
);
Future updateInboundGroupSessionIndexes(
String indexes,
String roomId,
String sessionId,
);
Future storeInboundGroupSession(
String roomId,
String sessionId,
String pickle,
String content,
String indexes,
String allowedAtIndex,
String senderKey,
String senderClaimedKey,
);
Future markInboundGroupSessionAsUploaded(
String roomId,
String sessionId,
);
Future updateInboundGroupSessionAllowedAtIndex(
String allowedAtIndex,
String roomId,
String sessionId,
);
Future removeOutboundGroupSession(String roomId);
Future storeOutboundGroupSession(
String roomId,
String pickle,
String deviceIds,
int creationTime,
);
Future updateClientKeys(
String olmAccount,
);
Future storeOlmSession(
String identitiyKey,
String sessionId,
String pickle,
int lastReceived,
);
Future setLastActiveUserDeviceKey(
int lastActive,
String userId,
String deviceId,
);
Future setLastSentMessageUserDeviceKey(
String lastSentMessage,
String userId,
String deviceId,
);
Future clearSSSSCache();
Future storeSSSSCache(
String type,
String keyId,
String ciphertext,
String content,
);
Future markInboundGroupSessionsAsNeedingUpload();
Future storePrevBatch(
String prevBatch,
);
Future deleteOldFiles(int savedAt);
Future storeUserDeviceKeysInfo(
String userId,
bool outdated,
);
Future storeUserDeviceKey(
String userId,
String deviceId,
String content,
bool verified,
bool blocked,
int lastActive,
);
Future removeUserDeviceKey(
String userId,
String deviceId,
);
Future removeUserCrossSigningKey(
String userId,
String publicKey,
);
Future storeUserCrossSigningKey(
String userId,
String publicKey,
String content,
bool verified,
bool blocked,
);
Future deleteFromToDeviceQueue(int id);
Future removeEvent(String eventId, String roomId);
Future updateRoomSortOrder(
double oldestSortOrder,
double newestSortOrder,
String roomId,
);
Future setRoomPrevBatch(
String prevBatch,
String roomId,
Client client,
);
Future resetNotificationCount(String roomId);
Future setVerifiedUserCrossSigningKey(
bool verified,
String userId,
String publicKey,
);
Future setBlockedUserCrossSigningKey(
bool blocked,
String userId,
String publicKey,
);
Future setVerifiedUserDeviceKey(
bool verified,
String userId,
String deviceId,
);
Future setBlockedUserDeviceKey(
bool blocked,
String userId,
String deviceId,
);
Future<List<Event>> getUnimportantRoomEventStatesForRoom(
List<String> events,
Room room,
);
Future<List<OlmSession>> getOlmSessions(
String identityKey,
String userId,
);
Future<Map<String, Map>> getAllOlmSessions();
Future<List<OlmSession>> getOlmSessionsForDevices(
List<String> identityKeys,
String userId,
);
Future<List<QueuedToDeviceEvent>> getToDeviceEventQueue();
/// Please do `jsonEncode(content)` in your code to stay compatible with
/// auto generated methods here.
Future insertIntoToDeviceQueue(
String type,
String txnId,
String content,
);
Future<List<String>> getLastSentMessageUserDeviceKey(
String userId,
String deviceId,
);
Future<List<StoredInboundGroupSession>> getInboundGroupSessionsToUpload();
Future<void> addSeenDeviceId(
String userId, String deviceId, String publicKeys);
Future<void> addSeenPublicKey(String publicKey, String deviceId);
Future<String?> deviceIdSeen(userId, deviceId);
Future<String?> publicKeySeen(String publicKey);
Future<dynamic> close();
Future<T> transaction<T>(Future<T> Function() action);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

792
lib/src/event.dart Normal file
View file

@ -0,0 +1,792 @@
/*
* 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:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import '../matrix.dart';
import 'utils/event_localizations.dart';
import 'utils/html_to_text.dart';
abstract class RelationshipTypes {
static const String reply = 'm.in_reply_to';
static const String edit = 'm.replace';
static const String reaction = 'm.annotation';
}
/// All data exchanged over Matrix is expressed as an "event". Typically each client action (e.g. sending a message) correlates with exactly one event.
class Event extends MatrixEvent {
User get sender => room.getUserByMXIDSync(senderId);
@Deprecated('Use [originServerTs] instead')
DateTime get time => originServerTs;
@Deprecated('Use [type] instead')
String get typeKey => type;
@Deprecated('Use [sender.calcDisplayname()] instead')
String? get senderName => sender.calcDisplayname();
/// The room this event belongs to. May be null.
final Room room;
/// The status of this event.
EventStatus status;
static const EventStatus defaultStatus = EventStatus.synced;
/// Optional. The event that redacted this event, if any. Otherwise null.
Event? get redactedBecause {
final redacted_because = unsigned?['redacted_because'];
final room = this.room;
return (redacted_because is Map<String, dynamic>)
? Event.fromJson(redacted_because, room)
: null;
}
bool get redacted => redactedBecause != null;
User? get stateKeyUser => room.getUserByMXIDSync(stateKey!);
Event({
this.status = defaultStatus,
required Map<String, dynamic> content,
required String type,
required String eventId,
required String senderId,
required DateTime originServerTs,
Map<String, dynamic>? unsigned,
Map<String, dynamic>? prevContent,
String? stateKey,
required this.room,
}) : super(
content: content,
type: type,
eventId: eventId,
senderId: senderId,
originServerTs: originServerTs,
roomId: room.id,
) {
this.eventId = eventId;
this.unsigned = unsigned;
// synapse unfortunately isn't following the spec and tosses the prev_content
// into the unsigned block.
// Currently we are facing a very strange bug in web which is impossible to debug.
// It may be because of this line so we put this in try-catch until we can fix it.
try {
this.prevContent = (prevContent != null && prevContent.isNotEmpty)
? prevContent
: (unsigned != null &&
unsigned.containsKey('prev_content') &&
unsigned['prev_content'] is Map)
? unsigned['prev_content']
: null;
} catch (_) {
// A strange bug in dart web makes this crash
}
this.stateKey = stateKey;
// Mark event as failed to send if status is `sending` and event is older
// than the timeout. This should not happen with the deprecated Moor
// database!
if (status.isSending && room.client.database != null) {
// Age of this event in milliseconds
final age = DateTime.now().millisecondsSinceEpoch -
originServerTs.millisecondsSinceEpoch;
final room = this.room;
if (age > room.client.sendMessageTimeoutSeconds * 1000) {
// Update this event in database and open timelines
final json = toJson();
json['unsigned'] ??= <String, dynamic>{};
json['unsigned'][messageSendingStatusKey] = EventStatus.error.intValue;
room.client.handleSync(
SyncUpdate(
nextBatch: '',
rooms: RoomsUpdate(
join: {
room.id: JoinedRoomUpdate(
timeline: TimelineUpdate(
events: [MatrixEvent.fromJson(json)],
),
)
},
),
),
);
}
}
}
static Map<String, dynamic> getMapFromPayload(dynamic payload) {
if (payload is String) {
try {
return json.decode(payload);
} catch (e) {
return {};
}
}
if (payload is Map<String, dynamic>) return payload;
return {};
}
factory Event.fromMatrixEvent(
MatrixEvent matrixEvent,
Room room, {
EventStatus status = defaultStatus,
}) =>
Event(
status: status,
content: matrixEvent.content,
type: matrixEvent.type,
eventId: matrixEvent.eventId,
senderId: matrixEvent.senderId,
originServerTs: matrixEvent.originServerTs,
unsigned: matrixEvent.unsigned,
prevContent: matrixEvent.prevContent,
stateKey: matrixEvent.stateKey,
room: room,
);
/// Get a State event from a table row or from the event stream.
factory Event.fromJson(
Map<String, dynamic> jsonPayload,
Room room,
) {
final content = Event.getMapFromPayload(jsonPayload['content']);
final unsigned = Event.getMapFromPayload(jsonPayload['unsigned']);
final prevContent = Event.getMapFromPayload(jsonPayload['prev_content']);
return Event(
status: eventStatusFromInt(jsonPayload['status'] ??
unsigned[messageSendingStatusKey] ??
defaultStatus.intValue),
stateKey: jsonPayload['state_key'],
prevContent: prevContent,
content: content,
type: jsonPayload['type'],
eventId: jsonPayload['event_id'] ?? '',
senderId: jsonPayload['sender'],
originServerTs: jsonPayload.containsKey('origin_server_ts')
? DateTime.fromMillisecondsSinceEpoch(jsonPayload['origin_server_ts'])
: DateTime.now(),
unsigned: unsigned,
room: room,
);
}
@override
Map<String, dynamic> toJson() {
final data = <String, dynamic>{};
if (stateKey != null) data['state_key'] = stateKey;
if (prevContent?.isNotEmpty == true) {
data['prev_content'] = prevContent;
}
data['content'] = content;
data['type'] = type;
data['event_id'] = eventId;
data['room_id'] = roomId;
data['sender'] = senderId;
data['origin_server_ts'] = originServerTs.millisecondsSinceEpoch;
if (unsigned?.isNotEmpty == true) {
data['unsigned'] = unsigned;
}
return data;
}
User get asUser => User.fromState(
// state key should always be set for member events
stateKey: stateKey!,
prevContent: prevContent,
content: content,
typeKey: type,
eventId: eventId,
roomId: roomId,
senderId: senderId,
originServerTs: originServerTs,
unsigned: unsigned,
room: room);
String get messageType => type == EventTypes.Sticker
? MessageTypes.Sticker
: (content['msgtype'] is String ? content['msgtype'] : MessageTypes.Text);
void setRedactionEvent(Event redactedBecause) {
unsigned = {
'redacted_because': redactedBecause.toJson(),
};
prevContent = null;
final contentKeyWhiteList = <String>[];
switch (type) {
case EventTypes.RoomMember:
contentKeyWhiteList.add('membership');
break;
case EventTypes.RoomCreate:
contentKeyWhiteList.add('creator');
break;
case EventTypes.RoomJoinRules:
contentKeyWhiteList.add('join_rule');
break;
case EventTypes.RoomPowerLevels:
contentKeyWhiteList.add('ban');
contentKeyWhiteList.add('events');
contentKeyWhiteList.add('events_default');
contentKeyWhiteList.add('kick');
contentKeyWhiteList.add('redact');
contentKeyWhiteList.add('state_default');
contentKeyWhiteList.add('users');
contentKeyWhiteList.add('users_default');
break;
case EventTypes.RoomAliases:
contentKeyWhiteList.add('aliases');
break;
case EventTypes.HistoryVisibility:
contentKeyWhiteList.add('history_visibility');
break;
default:
break;
}
content.removeWhere((k, v) => !contentKeyWhiteList.contains(k));
}
/// Returns the body of this event if it has a body.
String get text => content['body'] is String ? content['body'] : '';
/// Returns the formatted boy of this event if it has a formatted body.
String get formattedText =>
content['formatted_body'] is String ? content['formatted_body'] : '';
/// Use this to get the body.
String get body {
if (redacted) return 'Redacted';
if (text != '') return text;
if (formattedText != '') return formattedText;
return '$type';
}
/// Use this to get a plain-text representation of the event, stripping things
/// like spoilers and thelike. Useful for plain text notifications.
String get plaintextBody => content['format'] == 'org.matrix.custom.html'
? HtmlToText.convert(formattedText)
: body;
/// Returns a list of [Receipt] instances for this event.
List<Receipt> get receipts {
final room = this.room;
final receipt = room.roomAccountData['m.receipt'];
if (receipt == null) return [];
return receipt.content.entries
.where((entry) => entry.value['event_id'] == eventId)
.map((entry) => Receipt(room.getUserByMXIDSync(entry.key),
DateTime.fromMillisecondsSinceEpoch(entry.value['ts'])))
.toList();
}
/// Removes this event if the status is [sending], [error] or [removed].
/// This event will just be removed from the database and the timelines.
/// Returns [false] if not removed.
Future<bool> remove() async {
final room = this.room;
if (!status.isSent) {
await room.client.database?.removeEvent(eventId, room.id);
room.client.onEvent.add(EventUpdate(
roomID: room.id,
type: EventUpdateType.timeline,
content: {
'event_id': eventId,
'status': EventStatus.removed.intValue,
'content': {'body': 'Removed...'}
},
));
return true;
}
return false;
}
/// Try to send this event again. Only works with events of status -1.
Future<String?> sendAgain({String? txid}) async {
if (!status.isError) return null;
// we do not remove the event here. It will automatically be updated
// in the `sendEvent` method to transition -1 -> 0 -> 1 -> 2
final newEventId = await room.sendEvent(
content,
txid: txid ?? unsigned?['transaction_id'] ?? eventId,
);
return newEventId;
}
/// Whether the client is allowed to redact this event.
bool get canRedact => senderId == room.client.userID || room.canRedact;
/// Redacts this event. Throws `ErrorResponse` on error.
Future<String?> redactEvent({String? reason, String? txid}) async =>
await room.redactEvent(eventId, reason: reason, txid: txid);
/// Searches for the reply event in the given timeline.
Future<Event?> getReplyEvent(Timeline timeline) async {
if (relationshipType != RelationshipTypes.reply) return null;
final relationshipEventId = this.relationshipEventId;
return relationshipEventId == null
? null
: await timeline.getEventById(relationshipEventId);
}
/// If this event is encrypted and the decryption was not successful because
/// the session is unknown, this requests the session key from other devices
/// in the room. If the event is not encrypted or the decryption failed because
/// of a different error, this throws an exception.
Future<void> requestKey() async {
if (type != EventTypes.Encrypted ||
messageType != MessageTypes.BadEncrypted ||
content['can_request_session'] != true) {
throw ('Session key not requestable');
}
await room.requestSessionKey(content['session_id'], content['sender_key']);
return;
}
/// Gets the info map of file events, or a blank map if none present
Map get infoMap =>
content['info'] is Map ? content['info'] : <String, dynamic>{};
/// Gets the thumbnail info map of file events, or a blank map if nonepresent
Map get thumbnailInfoMap => infoMap['thumbnail_info'] is Map
? infoMap['thumbnail_info']
: <String, dynamic>{};
/// Returns if a file event has an attachment
bool get hasAttachment => content['url'] is String || content['file'] is Map;
/// Returns if a file event has a thumbnail
bool get hasThumbnail =>
infoMap['thumbnail_url'] is String || infoMap['thumbnail_file'] is Map;
/// Returns if a file events attachment is encrypted
bool get isAttachmentEncrypted => content['file'] is Map;
/// Returns if a file events thumbnail is encrypted
bool get isThumbnailEncrypted => infoMap['thumbnail_file'] is Map;
/// Gets the mimetype of the attachment of a file event, or a blank string if not present
String get attachmentMimetype => infoMap['mimetype'] is String
? infoMap['mimetype'].toLowerCase()
: (content['file'] is Map && content['file']['mimetype'] is String
? content['file']['mimetype']
: '');
/// Gets the mimetype of the thumbnail of a file event, or a blank string if not present
String get thumbnailMimetype => thumbnailInfoMap['mimetype'] is String
? thumbnailInfoMap['mimetype'].toLowerCase()
: (infoMap['thumbnail_file'] is Map &&
infoMap['thumbnail_file']['mimetype'] is String
? infoMap['thumbnail_file']['mimetype']
: '');
/// Gets the underlying mxc url of an attachment of a file event, or null if not present
Uri? get attachmentMxcUrl {
final url = isAttachmentEncrypted ? content['file']['url'] : content['url'];
return url is String ? Uri.tryParse(url) : null;
}
/// Gets the underlying mxc url of a thumbnail of a file event, or null if not present
Uri? get thumbnailMxcUrl {
final url = isThumbnailEncrypted
? infoMap['thumbnail_file']['url']
: infoMap['thumbnail_url'];
return url is String ? Uri.tryParse(url) : null;
}
/// Gets the mxc url of an attachment/thumbnail of a file event, taking sizes into account, or null if not present
Uri? attachmentOrThumbnailMxcUrl({bool getThumbnail = false}) {
if (getThumbnail &&
infoMap['size'] is int &&
thumbnailInfoMap['size'] is int &&
infoMap['size'] <= thumbnailInfoMap['size']) {
getThumbnail = false;
}
if (getThumbnail && !hasThumbnail) {
getThumbnail = false;
}
return getThumbnail ? thumbnailMxcUrl : attachmentMxcUrl;
}
// size determined from an approximate 800x800 jpeg thumbnail with method=scale
static const _minNoThumbSize = 80 * 1024;
/// Gets the attachment https URL to display in the timeline, taking into account if the original image is tiny.
/// Returns null for encrypted rooms, if the image can't be fetched via http url or if the event does not contain an attachment.
/// Set [getThumbnail] to true to fetch the thumbnail, set [width], [height] and [method]
/// for the respective thumbnailing properties.
/// [minNoThumbSize] is the minimum size that an original image may be to not fetch its thumbnail, defaults to 80k
/// [useThumbnailMxcUrl] says weather to use the mxc url of the thumbnail, rather than the original attachment.
/// [animated] says weather the thumbnail is animated
Uri? getAttachmentUrl(
{bool getThumbnail = false,
bool useThumbnailMxcUrl = false,
double width = 800.0,
double height = 800.0,
ThumbnailMethod method = ThumbnailMethod.scale,
int minNoThumbSize = _minNoThumbSize,
bool animated = false}) {
if (![EventTypes.Message, EventTypes.Sticker].contains(type) ||
!hasAttachment ||
isAttachmentEncrypted) {
return null; // can't url-thumbnail in encrypted rooms
}
if (useThumbnailMxcUrl && !hasThumbnail) {
return null; // can't fetch from thumbnail
}
final thisInfoMap = useThumbnailMxcUrl ? thumbnailInfoMap : infoMap;
final thisMxcUrl =
useThumbnailMxcUrl ? infoMap['thumbnail_url'] : content['url'];
// if we have as method scale, we can return safely the original image, should it be small enough
if (getThumbnail &&
method == ThumbnailMethod.scale &&
thisInfoMap['size'] is int &&
thisInfoMap['size'] < minNoThumbSize) {
getThumbnail = false;
}
// now generate the actual URLs
if (getThumbnail) {
return Uri.parse(thisMxcUrl).getThumbnail(
room.client,
width: width,
height: height,
method: method,
animated: animated,
);
} else {
return Uri.parse(thisMxcUrl).getDownloadLink(room.client);
}
}
/// Returns if an attachment is in the local store
Future<bool> isAttachmentInLocalStore({bool getThumbnail = false}) async {
if (![EventTypes.Message, EventTypes.Sticker].contains(type)) {
throw ("This event has the type '$type' and so it can't contain an attachment.");
}
final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail);
if (mxcUrl == null) {
throw "This event hasn't any attachment or thumbnail.";
}
getThumbnail = mxcUrl != attachmentMxcUrl;
// Is this file storeable?
final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
final database = room.client.database;
if (database == null) {
return false;
}
final storeable = thisInfoMap['size'] is int &&
thisInfoMap['size'] <= database.maxFileSize;
Uint8List? uint8list;
if (storeable) {
uint8list = await database.getFile(mxcUrl);
}
return uint8list != null;
}
/// Downloads (and decrypts if necessary) the attachment of this
/// event and returns it as a [MatrixFile]. If this event doesn't
/// contain an attachment, this throws an error. Set [getThumbnail] to
/// true to download the thumbnail instead.
Future<MatrixFile> downloadAndDecryptAttachment(
{bool getThumbnail = false,
Future<Uint8List> Function(Uri)? downloadCallback}) async {
if (![EventTypes.Message, EventTypes.Sticker].contains(type)) {
throw ("This event has the type '$type' and so it can't contain an attachment.");
}
final database = room.client.database;
final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail);
if (mxcUrl == null) {
throw "This event hasn't any attachment or thumbnail.";
}
getThumbnail = mxcUrl != attachmentMxcUrl;
final isEncrypted =
getThumbnail ? isThumbnailEncrypted : isAttachmentEncrypted;
if (isEncrypted && !room.client.encryptionEnabled) {
throw ('Encryption is not enabled in your Client.');
}
// Is this file storeable?
final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
var storeable = database != null &&
thisInfoMap['size'] is int &&
thisInfoMap['size'] <= database.maxFileSize;
Uint8List? uint8list;
if (storeable) {
uint8list = await room.client.database?.getFile(mxcUrl);
}
// Download the file
if (uint8list == null) {
downloadCallback ??= (Uri url) async => (await http.get(url)).bodyBytes;
uint8list = await downloadCallback(mxcUrl.getDownloadLink(room.client));
storeable = database != null &&
storeable &&
uint8list.lengthInBytes < database.maxFileSize;
if (storeable) {
await database.storeFile(
mxcUrl, uint8list, DateTime.now().millisecondsSinceEpoch);
}
}
// Decrypt the file
if (isEncrypted) {
final fileMap =
getThumbnail ? infoMap['thumbnail_file'] : content['file'];
if (!fileMap['key']['key_ops'].contains('decrypt')) {
throw ("Missing 'decrypt' in 'key_ops'.");
}
final encryptedFile = EncryptedFile(
data: uint8list,
iv: fileMap['iv'],
k: fileMap['key']['k'],
sha256: fileMap['hashes']['sha256'],
);
uint8list = await room.client.runInBackground<Uint8List?, EncryptedFile>(
decryptFile, encryptedFile);
if (uint8list == null) {
throw ('Unable to decrypt file');
}
}
return MatrixFile(bytes: uint8list, name: body);
}
/// Returns if this is a known event type.
bool get isEventTypeKnown =>
EventLocalizations.localizationsMap.containsKey(type);
/// Returns a localized String representation of this event. For a
/// room list you may find [withSenderNamePrefix] useful. Set [hideReply] to
/// crop all lines starting with '>'. With [plaintextBody] it'll use the
/// plaintextBody instead of the normal body.
String getLocalizedBody(
MatrixLocalizations i18n, {
bool withSenderNamePrefix = false,
bool hideReply = false,
bool hideEdit = false,
bool plaintextBody = false,
}) {
if (redacted) {
return i18n.removedBy(redactedBecause?.sender.calcDisplayname() ?? '');
}
var body = plaintextBody ? this.plaintextBody : this.body;
// we need to know if the message is an html message to be able to determine
// if we need to strip the reply fallback.
var htmlMessage = content['format'] != 'org.matrix.custom.html';
// If we have an edit, we want to operate on the new content
if (hideEdit &&
relationshipType == RelationshipTypes.edit &&
content.tryGet<Map<String, dynamic>>('m.new_content') != null) {
if (plaintextBody &&
content['m.new_content']['format'] == 'org.matrix.custom.html') {
htmlMessage = true;
body = HtmlToText.convert(
(content['m.new_content'] as Map<String, dynamic>)
.tryGet<String>('formatted_body') ??
formattedText);
} else {
htmlMessage = false;
body = (content['m.new_content'] as Map<String, dynamic>)
.tryGet<String>('body') ??
body;
}
}
// Hide reply fallback
// Be sure that the plaintextBody already stripped teh reply fallback,
// if the message is formatted
if (hideReply && (!plaintextBody || htmlMessage)) {
body = body.replaceFirst(
RegExp(r'^>( \*)? <[^>]+>[^\n\r]+\r?\n(> [^\n]*\r?\n)*\r?\n'), '');
}
final callback = EventLocalizations.localizationsMap[type];
var localizedBody = i18n.unknownEvent(type);
if (callback != null) {
localizedBody = callback(this, i18n, body);
}
// Add the sender name prefix
if (withSenderNamePrefix &&
type == EventTypes.Message &&
textOnlyMessageTypes.contains(messageType)) {
final senderNameOrYou = senderId == room.client.userID
? i18n.you
: (sender.calcDisplayname());
localizedBody = '$senderNameOrYou: $localizedBody';
}
return localizedBody;
}
static const Set<String> textOnlyMessageTypes = {
MessageTypes.Text,
MessageTypes.Notice,
MessageTypes.Emote,
MessageTypes.None,
};
/// returns if this event matches the passed event or transaction id
bool matchesEventOrTransactionId(String? search) {
if (search == null) {
return false;
}
if (eventId == search) {
return true;
}
return unsigned?['transaction_id'] == search;
}
/// Get the relationship type of an event. `null` if there is none
String? get relationshipType {
if (content.tryGet<Map<String, dynamic>>('m.relates_to') == null) {
return null;
}
if (content['m.relates_to'].containsKey('m.in_reply_to')) {
return RelationshipTypes.reply;
}
return content
.tryGet<Map<String, dynamic>>('m.relates_to')
?.tryGet<String>('rel_type');
}
/// Get the event ID that this relationship will reference. `null` if there is none
String? get relationshipEventId {
if (!(content['m.relates_to'] is Map)) {
return null;
}
if (content['m.relates_to'].containsKey('event_id')) {
return content['m.relates_to']['event_id'];
}
if (content['m.relates_to']['m.in_reply_to'] is Map &&
content['m.relates_to']['m.in_reply_to'].containsKey('event_id')) {
return content['m.relates_to']['m.in_reply_to']['event_id'];
}
return null;
}
/// Get whether this event has aggregated events from a certain [type]
/// To be able to do that you need to pass a [timeline]
bool hasAggregatedEvents(Timeline timeline, String type) =>
timeline.aggregatedEvents[eventId]?.containsKey(type) == true;
/// Get all the aggregated event objects for a given [type]. To be able to do this
/// you have to pass a [timeline]
Set<Event> aggregatedEvents(Timeline timeline, String type) =>
timeline.aggregatedEvents[eventId]?[type] ?? <Event>{};
/// Fetches the event to be rendered, taking into account all the edits and the like.
/// It needs a [timeline] for that.
Event getDisplayEvent(Timeline timeline) {
if (redacted) {
return this;
}
if (hasAggregatedEvents(timeline, RelationshipTypes.edit)) {
// alright, we have an edit
final allEditEvents = aggregatedEvents(timeline, RelationshipTypes.edit)
// we only allow edits made by the original author themself
.where((e) => e.senderId == senderId && e.type == EventTypes.Message)
.toList();
// we need to check again if it isn't empty, as we potentially removed all
// aggregated edits
if (allEditEvents.isNotEmpty) {
allEditEvents.sort((a, b) => a.originServerTs.millisecondsSinceEpoch -
b.originServerTs.millisecondsSinceEpoch >
0
? 1
: -1);
final rawEvent = allEditEvents.last.toJson();
// update the content of the new event to render
if (rawEvent['content']['m.new_content'] is Map) {
rawEvent['content'] = rawEvent['content']['m.new_content'];
}
return Event.fromJson(rawEvent, room);
}
}
return this;
}
/// returns if a message is a rich message
bool get isRichMessage =>
content['format'] == 'org.matrix.custom.html' &&
content['formatted_body'] is String;
// regexes to fetch the number of emotes, including emoji, and if the message consists of only those
// to match an emoji we can use the following regex:
// (?:\x{00a9}|\x{00ae}|[\x{2600}-\x{27bf}]|[\x{2b00}-\x{2bff}]|\x{d83c}[\x{d000}-\x{dfff}]|\x{d83d}[\x{d000}-\x{dfff}]|\x{d83e}[\x{d000}-\x{dfff}])[\x{fe00}-\x{fe0f}]?
// we need to replace \x{0000} with \u0000, the comment is left in the other format to be able to paste into regex101.com
// to see if there is a custom emote, we use the following regex: <img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>
// now we combind the two to have four regexes:
// 1. are there only emoji, or whitespace
// 2. are there only emoji, emotes, or whitespace
// 3. count number of emoji
// 4- count number of emoji or emotes
static final RegExp _onlyEmojiRegex = RegExp(
r'^((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|\s)*$',
caseSensitive: false,
multiLine: false);
static final RegExp _onlyEmojiEmoteRegex = RegExp(
r'^((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|<img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>|\s)*$',
caseSensitive: false,
multiLine: false);
static final RegExp _countEmojiRegex = RegExp(
r'((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?)',
caseSensitive: false,
multiLine: false);
static final RegExp _countEmojiEmoteRegex = RegExp(
r'((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|<img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>)',
caseSensitive: false,
multiLine: false);
/// Returns if a given event only has emotes, emojis or whitespace as content.
/// If the body contains a reply then it is stripped.
/// This is useful to determine if stand-alone emotes should be displayed bigger.
bool get onlyEmotes {
if (isRichMessage) {
final formattedTextStripped = formattedText.replaceAll(
RegExp('<mx-reply>.*<\/mx-reply>',
caseSensitive: false, multiLine: false, dotAll: true),
'');
return _onlyEmojiEmoteRegex.hasMatch(formattedTextStripped);
} else {
return _onlyEmojiRegex.hasMatch(plaintextBody);
}
}
/// Gets the number of emotes in a given message. This is useful to determine
/// if the emotes should be displayed bigger.
/// If the body contains a reply then it is stripped.
/// WARNING: This does **not** test if there are only emotes. Use `event.onlyEmotes` for that!
int get numberEmotes {
if (isRichMessage) {
final formattedTextStripped = formattedText.replaceAll(
RegExp('<mx-reply>.*<\/mx-reply>',
caseSensitive: false, multiLine: false, dotAll: true),
'');
return _countEmojiEmoteRegex.allMatches(formattedTextStripped).length;
} else {
return _countEmojiRegex.allMatches(plaintextBody).length;
}
}
}

70
lib/src/event_status.dart Normal file
View file

@ -0,0 +1,70 @@
/// Defines event status:
/// - removed
/// - error: (http request failed)
/// - sending: (http request started)
/// - sent: (http request successful)
/// - synced: (event came from sync loop)
/// - roomState
enum EventStatus {
removed,
error,
sending,
sent,
synced,
roomState,
}
/// Returns `EventStatusEnum` value from `intValue`.
///
/// - -2 == removed;
/// - -1 == error;
/// - 0 == sending;
/// - 1 == sent;
/// - 2 == synced;
/// - 3 == roomState;
EventStatus eventStatusFromInt(int intValue) =>
EventStatus.values[intValue + 2];
/// Takes two [EventStatus] values and returns the one with higher
/// (better in terms of message sending) status.
EventStatus latestEventStatus(EventStatus status1, EventStatus status2) =>
status1.intValue > status2.intValue ? status1 : status2;
extension EventStatusExtension on EventStatus {
/// Returns int value of the event status.
///
/// - -2 == removed;
/// - -1 == error;
/// - 0 == sending;
/// - 1 == sent;
/// - 2 == synced;
/// - 3 == roomState;
int get intValue => (index - 2);
/// Return `true` if the `EventStatus` equals `removed`.
bool get isRemoved => this == EventStatus.removed;
/// Return `true` if the `EventStatus` equals `error`.
bool get isError => this == EventStatus.error;
/// Return `true` if the `EventStatus` equals `sending`.
bool get isSending => this == EventStatus.sending;
/// Return `true` if the `EventStatus` equals `roomState`.
bool get isRoomState => this == EventStatus.roomState;
/// Returns `true` if the status is sent or later:
/// [EventStatus.sent], [EventStatus.synced] or [EventStatus.roomState].
bool get isSent => [
EventStatus.sent,
EventStatus.synced,
EventStatus.roomState
].contains(this);
/// Returns `true` if the status is `synced` or `roomState`:
/// [EventStatus.synced] or [EventStatus.roomState].
bool get isSynced => [
EventStatus.synced,
EventStatus.roomState,
].contains(this);
}

2100
lib/src/room.dart Normal file

File diff suppressed because it is too large Load diff

370
lib/src/timeline.dart Normal file
View file

@ -0,0 +1,370 @@
/*
* 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';
import 'package:collection/src/iterable_extensions.dart';
import '../matrix.dart';
/// Represents the timeline of a room. The callback [onUpdate] will be triggered
/// automatically. The initial
/// event list will be retreived when created by the `room.getTimeline()` method.
class Timeline {
final Room room;
final List<Event> events;
/// Map of event ID to map of type to set of aggregated events
final Map<String, Map<String, Set<Event>>> aggregatedEvents = {};
final void Function()? onUpdate;
final void Function(int index)? onChange;
final void Function(int index)? onInsert;
final void Function(int index)? onRemove;
StreamSubscription<EventUpdate>? sub;
StreamSubscription<SyncUpdate>? roomSub;
StreamSubscription<String>? sessionIdReceivedSub;
bool isRequestingHistory = false;
final Map<String, Event> _eventCache = {};
/// Searches for the event in this timeline. If not
/// found, requests from the server. Requested events
/// are cached.
Future<Event?> getEventById(String id) async {
for (final event in events) {
if (event.eventId == id) return event;
}
if (_eventCache.containsKey(id)) return _eventCache[id];
final requestedEvent = await room.getEventById(id);
if (requestedEvent == null) return null;
_eventCache[id] = requestedEvent;
return _eventCache[id];
}
// When fetching history, we will collect them into the `_historyUpdates` set
// first, and then only process all events at once, once we have the full history.
// This ensures that the entire history fetching only triggers `onUpdate` only *once*,
// even if /sync's complete while history is being proccessed.
bool _collectHistoryUpdates = false;
bool get canRequestHistory {
if (events.isEmpty) return true;
return events.last.type != EventTypes.RoomCreate;
}
Future<void> requestHistory(
{int historyCount = Room.defaultHistoryCount}) async {
if (isRequestingHistory) {
return;
}
isRequestingHistory = true;
onUpdate?.call();
try {
// Look up for events in hive first
final eventsFromStore = await room.client.database?.getEventList(
room,
start: events.length,
limit: Room.defaultHistoryCount,
);
if (eventsFromStore != null && eventsFromStore.isNotEmpty) {
events.addAll(eventsFromStore);
final startIndex = events.length - eventsFromStore.length;
final endIndex = events.length;
for (var i = startIndex; i < endIndex; i++) {
onInsert?.call(i);
}
} else {
Logs().v('No more events found in the store. Request from server...');
await room.requestHistory(
historyCount: historyCount,
onHistoryReceived: () {
_collectHistoryUpdates = true;
},
);
}
} finally {
_collectHistoryUpdates = false;
isRequestingHistory = false;
onUpdate?.call();
}
}
Timeline({
required this.room,
List<Event>? events,
this.onUpdate,
this.onChange,
this.onInsert,
this.onRemove,
}) : events = events ?? [] {
sub = room.client.onEvent.stream.listen(_handleEventUpdate);
// If the timeline is limited we want to clear our events cache
roomSub = room.client.onSync.stream
.where((sync) => sync.rooms?.join?[room.id]?.timeline?.limited == true)
.listen(_removeEventsNotInThisSync);
sessionIdReceivedSub =
room.onSessionKeyReceived.stream.listen(_sessionKeyReceived);
// we want to populate our aggregated events
for (final e in this.events) {
addAggregatedEvent(e);
}
}
/// Removes all entries from [events] which are not in this SyncUpdate.
void _removeEventsNotInThisSync(SyncUpdate sync) {
final newSyncEvents = sync.rooms?.join?[room.id]?.timeline?.events ?? [];
final keepEventIds = newSyncEvents.map((e) => e.eventId);
events.removeWhere((e) => !keepEventIds.contains(e.eventId));
}
/// Don't forget to call this before you dismiss this object!
void cancelSubscriptions() {
sub?.cancel();
roomSub?.cancel();
sessionIdReceivedSub?.cancel();
}
void _sessionKeyReceived(String sessionId) async {
var decryptAtLeastOneEvent = false;
final decryptFn = () async {
final encryption = room.client.encryption;
if (!room.client.encryptionEnabled || encryption == null) {
return;
}
for (var i = 0; i < events.length; i++) {
if (events[i].type == EventTypes.Encrypted &&
events[i].messageType == MessageTypes.BadEncrypted &&
events[i].content['session_id'] == sessionId) {
events[i] = await encryption.decryptRoomEvent(room.id, events[i],
store: true);
onChange?.call(i);
if (events[i].type != EventTypes.Encrypted) {
decryptAtLeastOneEvent = true;
}
}
}
};
if (room.client.database != null) {
await room.client.database?.transaction(decryptFn);
} else {
await decryptFn();
}
if (decryptAtLeastOneEvent) onUpdate?.call();
}
/// Request the keys for undecryptable events of this timeline
void requestKeys() {
for (final event in events) {
if (event.type == EventTypes.Encrypted &&
event.messageType == MessageTypes.BadEncrypted &&
event.content['can_request_session'] == true) {
try {
room.client.encryption?.keyManager.maybeAutoRequest(room.id,
event.content['session_id'], event.content['sender_key']);
} catch (_) {
// dispose
}
}
}
}
/// Set the read marker to the last synced event in this timeline.
Future<void> setReadMarker([String? eventId]) async {
eventId ??=
events.firstWhereOrNull((event) => event.status.isSynced)?.eventId;
if (eventId == null) return;
return room.setReadMarker(eventId, mRead: eventId);
}
int _findEvent({String? event_id, String? unsigned_txid}) {
// we want to find any existing event where either the passed event_id or the passed unsigned_txid
// matches either the event_id or transaction_id of the existing event.
// For that we create two sets, searchNeedle, what we search, and searchHaystack, where we check if there is a match.
// Now, after having these two sets, if the intersect between them is non-empty, we know that we have at least one match in one pair,
// thus meaning we found our element.
final searchNeedle = <String>{};
if (event_id != null) {
searchNeedle.add(event_id);
}
if (unsigned_txid != null) {
searchNeedle.add(unsigned_txid);
}
int i;
for (i = 0; i < events.length; i++) {
final searchHaystack = <String>{events[i].eventId};
final txnid = events[i].unsigned?['transaction_id'];
if (txnid != null) {
searchHaystack.add(txnid);
}
if (searchNeedle.intersection(searchHaystack).isNotEmpty) {
break;
}
}
return i;
}
void _removeEventFromSet(Set<Event> eventSet, Event event) {
eventSet.removeWhere((e) =>
e.matchesEventOrTransactionId(event.eventId) ||
(event.unsigned != null &&
e.matchesEventOrTransactionId(event.unsigned?['transaction_id'])));
}
void addAggregatedEvent(Event event) {
// we want to add an event to the aggregation tree
final relationshipType = event.relationshipType;
final relationshipEventId = event.relationshipEventId;
if (relationshipType == null || relationshipEventId == null) {
return; // nothing to do
}
final events = (aggregatedEvents[relationshipEventId] ??=
<String, Set<Event>>{})[relationshipType] ??= <Event>{};
// remove a potential old event
_removeEventFromSet(events, event);
// add the new one
events.add(event);
if (onChange != null) {
final index = _findEvent(event_id: relationshipEventId);
onChange?.call(index);
}
}
void removeAggregatedEvent(Event event) {
aggregatedEvents.remove(event.eventId);
if (event.unsigned != null) {
aggregatedEvents.remove(event.unsigned?['transaction_id']);
}
for (final types in aggregatedEvents.values) {
for (final events in types.values) {
_removeEventFromSet(events, event);
}
}
}
void _handleEventUpdate(EventUpdate eventUpdate, {bool update = true}) {
try {
if (eventUpdate.roomID != room.id) return;
if (eventUpdate.type != EventUpdateType.timeline &&
eventUpdate.type != EventUpdateType.history) {
return;
}
final status = eventStatusFromInt(eventUpdate.content['status'] ??
(eventUpdate.content['unsigned'] is Map<String, dynamic>
? eventUpdate.content['unsigned'][messageSendingStatusKey]
: null) ??
EventStatus.synced.intValue);
if (status.isRemoved) {
final i = _findEvent(event_id: eventUpdate.content['event_id']);
if (i < events.length) {
removeAggregatedEvent(events[i]);
events.removeAt(i);
onRemove?.call(i);
}
} else {
final i = _findEvent(
event_id: eventUpdate.content['event_id'],
unsigned_txid: eventUpdate.content['unsigned'] is Map
? eventUpdate.content['unsigned']['transaction_id']
: null);
if (i < events.length) {
// if the old status is larger than the new one, we also want to preserve the old status
final oldStatus = events[i].status;
events[i] = Event.fromJson(
eventUpdate.content,
room,
);
// do we preserve the status? we should allow 0 -> -1 updates and status increases
if ((latestEventStatus(status, oldStatus) == oldStatus) &&
!(status.isError && oldStatus.isSending)) {
events[i].status = oldStatus;
}
addAggregatedEvent(events[i]);
onChange?.call(i);
} else {
final newEvent = Event.fromJson(
eventUpdate.content,
room,
);
if (eventUpdate.type == EventUpdateType.history &&
events.indexWhere(
(e) => e.eventId == eventUpdate.content['event_id']) !=
-1) return;
var index = events.length;
if (eventUpdate.type == EventUpdateType.history) {
events.add(newEvent);
} else {
index = events.firstIndexWhereNotError;
events.insert(index, newEvent);
}
onInsert?.call(index);
addAggregatedEvent(newEvent);
}
}
// Handle redaction events
if (eventUpdate.content['type'] == EventTypes.Redaction) {
final index = _findEvent(event_id: eventUpdate.content['redacts']);
if (index < events.length) {
removeAggregatedEvent(events[index]);
// Is the redacted event a reaction? Then update the event this
// belongs to:
if (onChange != null) {
final relationshipEventId = events[index].relationshipEventId;
if (relationshipEventId != null) {
onChange?.call(_findEvent(event_id: relationshipEventId));
}
}
events[index].setRedactionEvent(Event.fromJson(
eventUpdate.content,
room,
));
onChange?.call(index);
}
}
if (update && !_collectHistoryUpdates) {
onUpdate?.call();
}
} catch (e, s) {
Logs().w('Handle event update failed', e, s);
}
}
}
extension on List<Event> {
int get firstIndexWhereNotError {
if (isEmpty) return 0;
final index = indexWhere((event) => !event.status.isError);
if (index == -1) return length;
return index;
}
}

242
lib/src/user.dart Normal file
View file

@ -0,0 +1,242 @@
/*
* 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';
/// Represents a Matrix User which may be a participant in a Matrix Room.
class User extends Event {
factory User(
String id, {
String? membership,
String? displayName,
String? avatarUrl,
required Room room,
}) {
return User.fromState(
stateKey: id,
content: {
if (membership != null) 'membership': membership,
if (displayName != null) 'displayname': displayName,
if (avatarUrl != null) 'avatar_url': avatarUrl,
},
typeKey: EventTypes.RoomMember,
roomId: room.id,
room: room,
originServerTs: DateTime.now(),
);
}
User.fromState({
dynamic prevContent,
required String stateKey,
dynamic content,
required String typeKey,
String eventId = 'fakevent',
String? roomId,
String senderId = 'fakesender',
required DateTime originServerTs,
dynamic unsigned,
required Room room,
}) : super(
stateKey: stateKey,
prevContent: prevContent,
content: content,
type: typeKey,
eventId: eventId,
senderId: senderId,
originServerTs: originServerTs,
unsigned: unsigned,
room: room,
);
/// The full qualified Matrix ID in the format @username:server.abc.
String get id => stateKey ?? '@unknown:unknown';
/// The displayname of the user if the user has set one.
String? get displayName =>
content.tryGet<String>('displayname') ??
prevContent?.tryGet<String>('displayname');
/// Returns the power level of this user.
int get powerLevel => room.getPowerLevelByUserId(id);
/// The membership status of the user. One of:
/// join
/// invite
/// leave
/// ban
Membership get membership => Membership.values.firstWhere((e) {
if (content['membership'] != null) {
return e.toString() == 'Membership.' + content['membership'];
}
return false;
}, orElse: () => Membership.join);
/// The avatar if the user has one.
Uri? get avatarUrl {
final prevContent = this.prevContent;
return content.containsKey('avatar_url')
? (content['avatar_url'] is String
? Uri.tryParse(content['avatar_url'])
: null)
: (prevContent != null && prevContent['avatar_url'] is String
? Uri.tryParse(prevContent['avatar_url'])
: null);
}
/// Returns the displayname or the local part of the Matrix ID if the user
/// has no displayname. If [formatLocalpart] is true, then the localpart will
/// be formatted in the way, that all "_" characters are becomming white spaces and
/// the first character of each word becomes uppercase.
/// If [mxidLocalPartFallback] is true, then the local part of the mxid will be shown
/// if there is no other displayname available. If not then this will return "Unknown user".
String calcDisplayname({
bool? formatLocalpart,
bool? mxidLocalPartFallback,
}) {
formatLocalpart ??= room.client.formatLocalpart;
mxidLocalPartFallback ??= room.client.mxidLocalPartFallback;
final displayName = this.displayName;
if (displayName != null && displayName.isNotEmpty) {
return displayName;
}
final stateKey = this.stateKey;
if (stateKey != null && mxidLocalPartFallback) {
if (!formatLocalpart) {
return stateKey.localpart ?? '';
}
final words = stateKey.localpart?.replaceAll('_', ' ').split(' ') ?? [];
for (var i = 0; i < words.length; i++) {
if (words[i].isNotEmpty) {
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
}
}
return words.join(' ').trim();
}
return 'Unknown user';
}
/// Call the Matrix API to kick this user from this room.
Future<void> kick() async => await room.kick(id);
/// Call the Matrix API to ban this user from this room.
Future<void> ban() async => await room.ban(id);
/// Call the Matrix API to unban this banned user from this room.
Future<void> unban() async => await room.unban(id);
/// Call the Matrix API to change the power level of this user.
Future<void> setPower(int power) async => await room.setPower(id, power);
/// Returns an existing direct chat ID with this user or creates a new one.
/// Returns null on error.
Future<String> startDirectChat({
bool? enableEncryption,
List<StateEvent>? initialState,
bool waitForSync = true,
}) async =>
room.client.startDirectChat(
id,
enableEncryption: enableEncryption,
initialState: initialState,
waitForSync: waitForSync,
);
/// The newest presence of this user if there is any and null if not.
Presence? get presence => room.client.presences[id];
/// Whether the client is able to ban/unban this user.
bool get canBan => room.canBan && powerLevel < room.ownPowerLevel;
/// Whether the client is able to kick this user.
bool get canKick =>
[Membership.join, Membership.invite].contains(membership) &&
room.canKick &&
powerLevel < room.ownPowerLevel;
/// Whether the client is allowed to change the power level of this user.
/// Please be aware that you can only set the power level to at least your own!
bool get canChangePowerLevel =>
room.canChangePowerLevel && powerLevel < room.ownPowerLevel;
@override
bool operator ==(dynamic other) => (other is User &&
other.id == id &&
other.room == room &&
other.membership == membership);
/// Get the mention text to use in a plain text body to mention this specific user
/// in this specific room
String get mention {
// if the displayname has [ or ] or : we can't build our more fancy stuff, so fall back to the id
// [] is used for the delimitors
// If we allowed : we could get collissions with the mxid fallbacks
final displayName = this.displayName;
if (displayName == null ||
displayName.isEmpty ||
{'[', ']', ':'}.any(displayName.contains)) {
return id;
}
final identifier = '@' +
// if we have non-word characters we need to surround with []
(RegExp(r'^\w+$').hasMatch(displayName)
? displayName
: '[$displayName]');
// get all the users with the same display name
final allUsersWithSameDisplayname = room.getParticipants();
allUsersWithSameDisplayname.removeWhere((user) =>
user.id == id ||
(user.displayName?.isEmpty ?? true) ||
user.displayName != displayName);
if (allUsersWithSameDisplayname.isEmpty) {
return identifier;
}
// ok, we have multiple users with the same display name....time to calculate a hash
final hashes = allUsersWithSameDisplayname.map((u) => _hash(u.id));
final ourHash = _hash(id);
// hash collission...just return our own mxid again
if (hashes.contains(ourHash)) {
return id;
}
return '$identifier#$ourHash';
}
/// Get the mention fragments for this user.
Set<String> get mentionFragments {
final displayName = this.displayName;
if (displayName == null ||
displayName.isEmpty ||
{'[', ']', ':'}.any(displayName.contains)) {
return {};
}
final identifier = '@' +
// if we have non-word characters we need to surround with []
(RegExp(r'^\w+$').hasMatch(displayName)
? displayName
: '[$displayName]');
final hash = _hash(id);
return {identifier, '$identifier#$hash'};
}
}
const _maximumHashLength = 10000;
String _hash(String s) =>
(s.codeUnits.fold<int>(0, (a, b) => a + b) % _maximumHashLength).toString();

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 }

1508
lib/src/voip.dart Normal file

File diff suppressed because it is too large Load diff

160
lib/src/voip_content.dart Normal file
View file

@ -0,0 +1,160 @@
/*
* 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/>.
*/
class CallReplacesTarget {
String? id;
String? display_name;
String? avatar_url;
CallReplacesTarget({this.id, this.display_name, this.avatar_url});
factory CallReplacesTarget.fromJson(Map<String, dynamic> json) =>
CallReplacesTarget(
id: json['id'].toString(),
display_name: json['display_name'].toString(),
avatar_url: json['avatar_url'].toString(),
);
Map<String, dynamic> toJson() => {
if (id != null) 'id': id,
if (display_name != null) 'display_name': display_name,
if (avatar_url != null) 'avatar_url': avatar_url,
};
}
/// MSC2747: VoIP call transfers
/// https://github.com/matrix-org/matrix-doc/pull/2747
class CallReplaces {
String? replacement_id;
CallReplacesTarget? target_user;
String? create_call;
String? await_call;
String? target_room;
CallReplaces({
this.replacement_id,
this.target_user,
this.create_call,
this.await_call,
this.target_room,
});
factory CallReplaces.fromJson(Map<String, dynamic> json) => CallReplaces(
replacement_id: json['replacement_id']?.toString(),
create_call: json['create_call']?.toString(),
await_call: json['await_call']?.toString(),
target_room: json['target_room']?.toString(),
target_user: CallReplacesTarget.fromJson(json['target_user']),
);
Map<String, dynamic> toJson() => {
if (replacement_id != null) 'replacement_id': replacement_id,
if (target_user != null) 'target_user': target_user!.toJson(),
if (create_call != null) 'create_call': create_call,
if (await_call != null) 'await_call': await_call,
if (target_room != null) 'target_room': target_room,
};
}
// TODO: Change to "sdp_stream_metadata" when MSC3077 is merged
const String sdpStreamMetadataKey = 'org.matrix.msc3077.sdp_stream_metadata';
/// https://github.com/matrix-org/matrix-doc/blob/dbkr/msc2747/proposals/2747-voip-call-transfer.md#capability-advertisment
/// https://github.com/matrix-org/matrix-doc/blob/dbkr/msc2746/proposals/2746-reliable-voip.md#add-dtmf
class CallCapabilities {
bool transferee;
bool dtmf;
CallCapabilities({this.transferee = false, this.dtmf = false});
factory CallCapabilities.fromJson(Map<String, dynamic> json) =>
CallCapabilities(
dtmf: json['m.call.dtmf'] as bool? ?? false,
transferee: json['m.call.transferee'] as bool? ?? false,
);
Map<String, dynamic> toJson() => {
'm.call.transferee': transferee,
'm.call.dtmf': dtmf,
};
}
/// MSC3077: Support for multi-stream VoIP
/// https://github.com/matrix-org/matrix-doc/pull/3077
///
/// MSC3291: Muting in VoIP calls
/// https://github.com/SimonBrandner/matrix-doc/blob/msc/muting/proposals/3291-muting.md
///
/// This MSC proposes adding an sdp_stream_metadata field
/// to the events containing a session description i.e.:
/// m.call.invite, m.call.answer, m.call.negotiate
///
class SDPStreamPurpose {
// SDPStreamMetadataPurpose
String purpose;
bool audio_muted;
bool video_muted;
SDPStreamPurpose(
{required this.purpose,
this.audio_muted = false,
this.video_muted = false});
factory SDPStreamPurpose.fromJson(Map<String, dynamic> json) =>
SDPStreamPurpose(
audio_muted: json['audio_muted'] as bool? ?? false,
video_muted: json['video_muted'] as bool? ?? false,
purpose: json['purpose'] as String,
);
Map<String, dynamic> toJson() => {
'purpose': purpose,
'audio_muted': audio_muted,
'video_muted': video_muted,
};
}
class SDPStreamMetadataPurpose {
static String Usermedia = 'm.usermedia';
static String Screenshare = 'm.screenshare';
}
class SDPStreamMetadata {
Map<String, SDPStreamPurpose> sdpStreamMetadatas;
SDPStreamMetadata(this.sdpStreamMetadatas);
factory SDPStreamMetadata.fromJson(Map<String, dynamic> json) =>
SDPStreamMetadata(json.map(
(key, value) => MapEntry(key, SDPStreamPurpose.fromJson(value))));
Map<String, dynamic> toJson() =>
sdpStreamMetadatas.map((key, value) => MapEntry(key, value.toJson()));
}
/// MSC3086: Asserted identity on VoIP calls
/// https://github.com/matrix-org/matrix-doc/pull/3086
class AssertedIdentity {
String? id;
String? displayName;
String? avatarUrl;
AssertedIdentity({this.id, this.displayName, this.avatarUrl});
factory AssertedIdentity.fromJson(Map<String, dynamic> json) =>
AssertedIdentity(
displayName: json['display_name'] as String?,
id: json['id'] as String?,
avatarUrl: json['avatar_url'] as String?,
);
Map<String, dynamic> toJson() => {
if (displayName != null) 'display_name': displayName,
if (id != null) 'id': id,
if (avatarUrl != null) 'avatar_url': avatarUrl,
};
}