Initial commit version 0.8.13
This commit is contained in:
commit
9526dfa4f2
111 changed files with 35074 additions and 0 deletions
2542
lib/src/client.dart
Normal file
2542
lib/src/client.dart
Normal file
File diff suppressed because it is too large
Load diff
309
lib/src/database/database_api.dart
Normal file
309
lib/src/database/database_api.dart
Normal 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);
|
||||
}
|
||||
1478
lib/src/database/fluffybox_database.dart
Normal file
1478
lib/src/database/fluffybox_database.dart
Normal file
File diff suppressed because it is too large
Load diff
1433
lib/src/database/hive_database.dart
Normal file
1433
lib/src/database/hive_database.dart
Normal file
File diff suppressed because it is too large
Load diff
792
lib/src/event.dart
Normal file
792
lib/src/event.dart
Normal 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
70
lib/src/event_status.dart
Normal 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
2100
lib/src/room.dart
Normal file
File diff suppressed because it is too large
Load diff
370
lib/src/timeline.dart
Normal file
370
lib/src/timeline.dart
Normal 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
242
lib/src/user.dart
Normal 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();
|
||||
237
lib/src/utils/commands_extension.dart
Normal file
237
lib/src/utils/commands_extension.dart
Normal 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});
|
||||
}
|
||||
29
lib/src/utils/crypto/crypto.dart
Normal file
29
lib/src/utils/crypto/crypto.dart
Normal 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;
|
||||
}
|
||||
60
lib/src/utils/crypto/encrypted_file.dart
Normal file
60
lib/src/utils/crypto/encrypted_file.dart
Normal 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);
|
||||
}
|
||||
123
lib/src/utils/crypto/ffi.dart
Normal file
123
lib/src/utils/crypto/ffi.dart
Normal 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');
|
||||
64
lib/src/utils/crypto/js.dart
Normal file
64
lib/src/utils/crypto/js.dart
Normal 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);
|
||||
}
|
||||
102
lib/src/utils/crypto/native.dart
Normal file
102
lib/src/utils/crypto/native.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
93
lib/src/utils/crypto/subtle.dart
Normal file
93
lib/src/utils/crypto/subtle.dart
Normal 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));
|
||||
}
|
||||
515
lib/src/utils/device_keys_list.dart
Normal file
515
lib/src/utils/device_keys_list.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
224
lib/src/utils/event_localizations.dart
Normal file
224
lib/src/utils/event_localizations.dart
Normal 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
72
lib/src/utils/event_update.dart
Normal file
72
lib/src/utils/event_update.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
259
lib/src/utils/html_to_text.dart
Normal file
259
lib/src/utils/html_to_text.dart
Normal 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;
|
||||
}
|
||||
115
lib/src/utils/http_timeout.dart
Normal file
115
lib/src/utils/http_timeout.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
95
lib/src/utils/image_pack_extension.dart
Normal file
95
lib/src/utils/image_pack_extension.dart
Normal 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()))));
|
||||
}
|
||||
33
lib/src/utils/map_copy_extension.dart
Normal file
33
lib/src/utils/map_copy_extension.dart
Normal 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
256
lib/src/utils/markdown.dart
Normal 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 />)+$'), '');
|
||||
}
|
||||
34
lib/src/utils/marked_unread.dart
Normal file
34
lib/src/utils/marked_unread.dart
Normal 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};
|
||||
}
|
||||
290
lib/src/utils/matrix_file.dart
Normal file
290
lib/src/utils/matrix_file.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
136
lib/src/utils/matrix_id_string_extension.dart
Normal file
136
lib/src/utils/matrix_id_string_extension.dart
Normal 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});
|
||||
}
|
||||
179
lib/src/utils/matrix_localizations.dart
Normal file
179
lib/src/utils/matrix_localizations.dart
Normal 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.', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
68
lib/src/utils/multilock.dart
Normal file
68
lib/src/utils/multilock.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
lib/src/utils/queued_to_device_event.dart
Normal file
50
lib/src/utils/queued_to_device_event.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
33
lib/src/utils/receipt.dart
Normal file
33
lib/src/utils/receipt.dart
Normal 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);
|
||||
}
|
||||
43
lib/src/utils/run_benchmarked.dart
Normal file
43
lib/src/utils/run_benchmarked.dart
Normal 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;
|
||||
}
|
||||
32
lib/src/utils/run_in_root.dart
Normal file
32
lib/src/utils/run_in_root.dart
Normal 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;
|
||||
});
|
||||
}
|
||||
47
lib/src/utils/space_child.dart
Normal file
47
lib/src/utils/space_child.dart
Normal 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');
|
||||
}
|
||||
44
lib/src/utils/sync_update_extension.dart
Normal file
44
lib/src/utils/sync_update_extension.dart
Normal 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;
|
||||
}
|
||||
41
lib/src/utils/sync_update_item_count.dart
Normal file
41
lib/src/utils/sync_update_item_count.dart
Normal 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;
|
||||
}
|
||||
53
lib/src/utils/to_device_event.dart
Normal file
53
lib/src/utils/to_device_event.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
120
lib/src/utils/uia_request.dart
Normal file
120
lib/src/utils/uia_request.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
63
lib/src/utils/uri_extension.dart
Normal file
63
lib/src/utils/uri_extension.dart
Normal 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
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
160
lib/src/voip_content.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue