diff --git a/lib/matrix_api_lite.dart b/lib/matrix_api_lite.dart index e75d38363..8b96e40e2 100644 --- a/lib/matrix_api_lite.dart +++ b/lib/matrix_api_lite.dart @@ -27,6 +27,7 @@ library; export 'matrix_api_lite/generated/model.dart'; export 'matrix_api_lite/matrix_api.dart'; export 'matrix_api_lite/model/algorithm_types.dart'; +export 'matrix_api_lite/model/matrix_id.dart'; export 'matrix_api_lite/model/auth/authentication_data.dart'; export 'matrix_api_lite/model/auth/authentication_identifier.dart'; export 'matrix_api_lite/model/auth/authentication_password.dart'; diff --git a/lib/matrix_api_lite/model/matrix_id.dart b/lib/matrix_api_lite/model/matrix_id.dart new file mode 100644 index 000000000..af4be15b0 --- /dev/null +++ b/lib/matrix_api_lite/model/matrix_id.dart @@ -0,0 +1,152 @@ +const Set matrixIdSigils = {'@', '!', '#', '\$', '+'}; + +const int matrixIdMaxLength = 255; + +extension on String { + String get _localpart => substring(1).split(':').first; + String? get _domain { + final colonIndex = indexOf(':'); + if (colonIndex == -1) return null; + return substring(indexOf(':') + 1); + } + + void _validate(String expectedSigil) { + if (this != trim()) { + throw Exception( + 'Invalid matrix id: String must not have leading or trailing whitespaces!', + ); + } + if (isEmpty) { + throw Exception('Invalid matrix id: String is empty!'); + } + + // Check total length (including sigil and domain) + if (length > matrixIdMaxLength) { + throw Exception('Invalid matrix id: Must not exceed 255 bytes!'); + } + + // Validate localpart is not empty + if (_localpart.isEmpty) { + throw Exception('Invalid matrix id: Localpart must not be empty!'); + } + + // Validate localpart doesn't contain invalid characters + if (_localpart.contains('\u0000')) { + throw Exception( + 'Invalid matrix id: Localpart must not contain NUL character!', + ); + } + + final sigil = this[0]; + + if (sigil != expectedSigil) { + throw Exception( + 'Invalid matrix id: Sigil was $sigil but expected $expectedSigil', + ); + } + + if (!matrixIdSigils.contains(sigil)) { + throw Exception('Invalid matrix id: Unknown sigil $sigil'); + } + + if ({'@', '#'}.contains(sigil) && _domain == null) { + throw Exception( + 'Invalid matrix ID: Domain is required for User IDs and Room Aliases!', + ); + } + + return; // Valid Matrix ID + } +} + +extension type UserId._(String matrixId) { + UserId(this.matrixId) { + matrixId._validate(sigil); + } + + UserId.from(String localpart, String domain) + : matrixId = '@$localpart:$domain'; + + static const String sigil = '@'; + + static UserId? tryParse(String string) { + try { + return UserId(string); + } catch (_) { + return null; + } + } + + void validate() => matrixId._validate(sigil); + String get localpart => matrixId._localpart; + String get domain => matrixId._domain!; +} + +extension type RoomId._(String matrixId) { + RoomId(this.matrixId) { + matrixId._validate(sigil); + } + + RoomId.from(String localpart, [String? domain]) + : matrixId = '$sigil$localpart${domain == null ? '' : ':$domain'}'; + + static const String sigil = '!'; + + static RoomId? tryParse(String string) { + try { + return RoomId(string); + } catch (_) { + return null; + } + } + + void validate() => matrixId._validate(sigil); + String get localpart => matrixId._localpart; + String? get domain => matrixId._domain; +} + +extension type RoomAlias._(String matrixId) { + RoomAlias(this.matrixId) { + matrixId._validate(sigil); + } + + RoomAlias.from(String localpart, String domain) + : matrixId = '$sigil$localpart:$domain'; + + static const String sigil = '#'; + + static RoomAlias? tryParse(String string) { + try { + return RoomAlias(string); + } catch (_) { + return null; + } + } + + void validate() => matrixId._validate(sigil); + String get localpart => matrixId._localpart; + String get domain => matrixId._domain!; +} + +extension type EventId._(String matrixId) { + EventId(this.matrixId) { + matrixId._validate(sigil); + } + + EventId.from(String localpart, [String? domain]) + : matrixId = '$sigil$localpart${domain == null ? '' : ':$domain'}'; + + static const String sigil = '\$'; + + static EventId? tryParse(String string) { + try { + return EventId(string); + } catch (_) { + return null; + } + } + + void validate() => matrixId._validate(sigil); + String get localpart => matrixId._localpart; + String? get domain => matrixId._domain; +} diff --git a/lib/src/client.dart b/lib/src/client.dart index f3a67a1e5..26083795a 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -428,7 +428,7 @@ class Client extends MatrixApi { return '$clientName-$_transactionCounter-${DateTime.now().millisecondsSinceEpoch}'; } - Room? getRoomByAlias(String alias) { + Room? getRoomByAlias(RoomAlias alias) { for (final room in rooms) { if (room.canonicalAlias == alias) return room; } diff --git a/lib/src/room.dart b/lib/src/room.dart index c6a7bd175..460b293b4 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -246,7 +246,7 @@ class Room { ]) { if (name.isNotEmpty) return name; - final canonicalAlias = this.canonicalAlias.localpart; + final canonicalAlias = this.canonicalAlias?.localpart; if (canonicalAlias != null && canonicalAlias.isNotEmpty) { return canonicalAlias; } @@ -322,17 +322,20 @@ class Room { } /// The address in the format: #roomname:homeserver.org. - String get canonicalAlias { - final alias = getState(EventTypes.RoomCanonicalAlias)?.content['alias']; - return (alias is String) ? alias : ''; + RoomAlias? get canonicalAlias { + final alias = getState(EventTypes.RoomCanonicalAlias) + ?.content + .tryGet('alias'); + if (alias == null) return null; + return RoomAlias.tryParse(alias); } /// Sets the canonical alias. If the [canonicalAlias] is not yet an alias of /// this room, it will create one. - Future setCanonicalAlias(String canonicalAlias) async { + Future setCanonicalAlias(RoomAlias canonicalAlias) async { final aliases = await client.getLocalAliases(id); - if (!aliases.contains(canonicalAlias)) { - await client.setRoomAlias(canonicalAlias, id); + if (!aliases.contains(canonicalAlias.matrixId)) { + await client.setRoomAlias(canonicalAlias.matrixId, id); } await client.setRoomStateWithKey(id, EventTypes.RoomCanonicalAlias, '', { 'alias': canonicalAlias, @@ -2661,9 +2664,10 @@ class Room { /// Generates a matrix.to link with appropriate routing info to share the room Future matrixToInviteLink() async { - if (canonicalAlias.isNotEmpty) { + final canonicalAlias = this.canonicalAlias; + if (canonicalAlias != null) { return Uri.parse( - 'https://matrix.to/#/${Uri.encodeComponent(canonicalAlias)}', + 'https://matrix.to/#/${Uri.encodeComponent(canonicalAlias.matrixId)}', ); } final List queryParameters = []; diff --git a/lib/src/utils/push_notification.dart b/lib/src/utils/push_notification.dart index 42e29e0d2..25b6881e8 100644 --- a/lib/src/utils/push_notification.dart +++ b/lib/src/utils/push_notification.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:matrix/matrix_api_lite.dart'; + /// Push Notification object from https://spec.matrix.org/v1.2/push-gateway-api/ class PushNotification { final Map? content; @@ -7,7 +9,7 @@ class PushNotification { final List? devices; final String? eventId; final String? prio; - final String? roomAlias; + final RoomAlias? roomAlias; final String? roomId; final String? roomName; final String? sender; @@ -57,7 +59,7 @@ class PushNotification { : null, eventId: json['event_id'] as String?, prio: json['prio'] as String?, - roomAlias: json['room_alias'] as String?, + roomAlias: json['room_alias'] as RoomAlias?, roomId: json['room_id'] as String?, roomName: json['room_name'] as String?, sender: json['sender'] as String?, diff --git a/pubspec.yaml b/pubspec.yaml index 38a9c50a8..55062b79f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ repository: https://github.com/famedly/matrix-dart-sdk.git issue_tracker: https://github.com/famedly/matrix-dart-sdk/issues environment: - sdk: ">=3.0.0 <4.0.0" + sdk: ">=3.3.0 <4.0.0" dependencies: async: ^2.8.0 diff --git a/test/client_test.dart b/test/client_test.dart index d600b6260..400bef5ca 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -299,7 +299,7 @@ void main() { expect( matrix.getRoomByAlias( - "#famedlyContactDiscovery:${matrix.userID!.split(":")[1]}", + RoomAlias.from('famedlyContactDiscovery', matrix.userID!.domain!), ), null, ); @@ -1707,7 +1707,7 @@ void main() { 'msgtype': 'm.text', 'body': 'Hello world', }, - roomAlias: '#testalias:blaaa', + roomAlias: RoomAlias('#testalias:blaaa'), roomName: 'TestRoomName', sender: '@alicyy:example.com', senderDisplayName: 'AlicE', diff --git a/test/matrix_api/matrix_id_test.dart b/test/matrix_api/matrix_id_test.dart new file mode 100644 index 000000000..403376bab --- /dev/null +++ b/test/matrix_api/matrix_id_test.dart @@ -0,0 +1,158 @@ +import 'package:test/test.dart'; + +import 'package:matrix/matrix_api_lite/model/matrix_id.dart'; + +void main() { + group('UserId', () { + test('parses and extracts components', () { + final userId = UserId('@alice:example.com'); + expect(userId.localpart, 'alice'); + expect(userId.domain, 'example.com'); + }); + + test('creates from localpart and domain', () { + final userId = UserId.from('bob', 'matrix.org'); + expect(userId.localpart, 'bob'); + }); + + test('tryParse returns null for invalid input', () { + expect(UserId.tryParse('not-a-user-id'), isNull); + expect(UserId.tryParse('@alice'), isNull); + }); + + test('rejects missing domain', () { + expect(() => UserId('@alice'), throwsException); + }); + + test('rejects wrong sigil', () { + expect(() => UserId('!alice:example.com'), throwsException); + }); + + test('has correct sigil constant', () { + expect(UserId.sigil, '@'); + }); + }); + + group('RoomId', () { + test('parses and extracts components', () { + final roomId = RoomId('!abc123:example.com'); + expect(roomId.localpart, 'abc123'); + expect(roomId.domain, 'example.com'); + }); + + test('allows optional domain', () { + final roomId = RoomId.from('ghi789'); + expect(roomId.domain, isNull); + }); + + test('tryParse returns null for invalid sigil', () { + expect(RoomId.tryParse('@alice:example.com'), isNull); + }); + + test('rejects wrong sigil', () { + expect(() => RoomId('@room:example.com'), throwsException); + }); + + test('has correct sigil constant', () { + expect(RoomId.sigil, '!'); + }); + }); + + group('RoomAlias', () { + test('parses and extracts components', () { + final alias = RoomAlias('#general:example.com'); + expect(alias.localpart, 'general'); + expect(alias.domain, 'example.com'); + }); + + test('requires domain', () { + expect(() => RoomAlias('#support'), throwsException); + }); + + test('tryParse returns null for missing domain', () { + expect(RoomAlias.tryParse('#general'), isNull); + }); + + test('rejects wrong sigil', () { + expect(() => RoomAlias('@admin:example.com'), throwsException); + }); + + test('has correct sigil constant', () { + expect(RoomAlias.sigil, '#'); + }); + }); + + group('EventId', () { + test('parses and extracts components', () { + final eventId = EventId('\$abc123:example.com'); + expect(eventId.localpart, 'abc123'); + expect(eventId.domain, 'example.com'); + }); + + test('allows optional domain', () { + final eventId = EventId.from('ghi789'); + expect(eventId.domain, isNull); + }); + + test('tryParse returns null for invalid sigil', () { + expect(EventId.tryParse('@alice:example.com'), isNull); + }); + + test('rejects wrong sigil', () { + expect(() => EventId('@event:example.com'), throwsException); + }); + + test('has correct sigil constant', () { + expect(EventId.sigil, '\$'); + }); + }); + + group('Validation', () { + test('rejects whitespace', () { + expect(() => UserId(' @alice:example.com'), throwsException); + expect(() => RoomAlias('#general:example.com '), throwsException); + }); + + test('rejects empty localpart', () { + expect(() => UserId('@:example.com'), throwsException); + }); + + test('rejects NUL character', () { + expect(() => UserId('@alice\u0000:example.com'), throwsException); + }); + + test('rejects strings exceeding 255 bytes', () { + final longLocalpart = 'a' * 250; + expect( + () => UserId('@$longLocalpart:example.com'), + throwsException, + ); + }); + }); + + group('Round-trip consistency', () { + test('from() matches direct constructor', () { + final userId1 = UserId.from('alice', 'example.com'); + final userId2 = UserId('@alice:example.com'); + expect(userId1.localpart, userId2.localpart); + expect(userId1.domain, userId2.domain); + }); + }); + + group('Special characters', () { + test('handles dots and plus in localpart', () { + final userId = UserId.from('user.name+test', 'example.com'); + expect(userId.localpart, 'user.name+test'); + }); + + test('handles hyphens and underscores', () { + final alias = RoomAlias.from('my-awesome_room', 'example.com'); + expect(alias.localpart, 'my-awesome_room'); + }); + + test('handles domain with port', () { + final userId = UserId('@alice:example.com:8008'); + expect(userId.domain, 'example.com:8008'); + }); + }); +} diff --git a/test_driver/matrixsdk_test.dart b/test_driver/matrixsdk_test.dart index 17888554e..5e4cb4018 100644 --- a/test_driver/matrixsdk_test.dart +++ b/test_driver/matrixsdk_test.dart @@ -74,7 +74,7 @@ void main() => group( Logs().i('++++ (Alice) Leave all rooms ++++'); while (testClientA.rooms.isNotEmpty) { final room = testClientA.rooms.first; - if (room.canonicalAlias.isNotEmpty) { + if (room.canonicalAlias != null) { break; } try { @@ -539,7 +539,7 @@ void main() => group( Logs().i('++++ (Alice) Leave all rooms ++++'); while (testClientA.rooms.isNotEmpty) { final room = testClientA.rooms.first; - if (room.canonicalAlias.isNotEmpty) { + if (room.canonicalAlias != null) { break; } try {