From 77be6102f6cbb2e01adc28f9caa3aa583f914235 Mon Sep 17 00:00:00 2001 From: Christian Pauly Date: Sun, 9 Jun 2019 12:16:48 +0200 Subject: [PATCH] Initial commit --- .gitignore | 70 ++++ .gitlab-ci.yml | 18 + .metadata | 10 + CHANGELOG.md | 84 +++++ LICENSE | 1 + README.md | 14 + lib/famedlysdk.dart | 13 + lib/src/Client.dart | 169 +++++++++ lib/src/Connection.dart | 415 +++++++++++++++++++++ lib/src/Event.dart | 120 +++++++ lib/src/Room.dart | 197 ++++++++++ lib/src/Store.dart | 516 +++++++++++++++++++++++++++ lib/src/User.dart | 33 ++ lib/src/responses/ErrorResponse.dart | 23 ++ lib/src/sync/EventUpdate.dart | 20 ++ lib/src/sync/RoomUpdate.dart | 33 ++ lib/src/utils/ChatTime.dart | 74 ++++ lib/src/utils/MxContent.dart | 24 ++ pubspec.lock | 287 +++++++++++++++ pubspec.yaml | 62 ++++ test/Client_test.dart | 216 +++++++++++ test/FakeMatrixApi.dart | 192 ++++++++++ 22 files changed, 2591 insertions(+) create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 .metadata create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 lib/famedlysdk.dart create mode 100644 lib/src/Client.dart create mode 100644 lib/src/Connection.dart create mode 100644 lib/src/Event.dart create mode 100644 lib/src/Room.dart create mode 100644 lib/src/Store.dart create mode 100644 lib/src/User.dart create mode 100644 lib/src/responses/ErrorResponse.dart create mode 100644 lib/src/sync/EventUpdate.dart create mode 100644 lib/src/sync/RoomUpdate.dart create mode 100644 lib/src/utils/ChatTime.dart create mode 100644 lib/src/utils/MxContent.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 test/Client_test.dart create mode 100644 test/FakeMatrixApi.dart diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d7edcf --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..1ece140 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,18 @@ +image: cirrusci/flutter + +stages: +- coverage + +variables: + LC_ALL: "en_US.UTF-8" + LANG: "en_US.UTF-8" + +coverage: + stage: coverage + coverage: '/^\s+lines.+: (\d+.\d*%)/' + dependencies: [] + script: + - sudo apt-get update -qq && sudo apt-get install -qq apt-transport-https curl gnupg lcov git + - ./scripts/test.sh + - ./scripts/coverage.sh + - flutter pub pub publish --dry-run \ No newline at end of file diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..5ab1e9a --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 7a4c33425ddd78c54aba07d86f3f9a4a0051769b + channel: stable + +project_type: package diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bb8d63f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,84 @@ +# fluffyfluttermatrix + +Dead simple Flutter widget to use Matrix.org in your Flutter app. + +## How to use this + +1. Use the Matrix widget as root for your widget tree: + +```dart +import 'package:flutter/material.dart'; +import 'package:fluffyfluttermatrix/fluffyfluttermatrix.dart'; + +void main() => runApp(MyApp()); + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return FluffyMatrix( + child: MaterialApp( + title: 'Welcome to Flutter' + ), + ); + } +} + +``` + +2. Access the MatrixState object by calling Matrix.of with your current BuildContext: + +```dart +Client matrix = Matrix.of(context); +``` + +3. Connect to a Matrix Homeserver and listen to the streams: + +```dart +matrix.homeserver = "https://yourhomeserveraddress"; + +matrix.onLoginStateChanged.stream.listen((bool loginState){ + print("LoginState: ${loginState.toString()}"); +}); + +matrix.onEvent.stream.listen((EventUpdate eventUpdate){ + print("New event update!"); +}); + +matrix.onRoomUpdate.stream.listen((RoomUpdate eventUpdate){ + print("New room update!"); +}); + +final loginResp = await matrix.jsonRequest( + type: "POST", + action: "/client/r0/login", + data: { + "type": "m.login.password", + "user": _usernameController.text, + "password": _passwordController.text, + "initial_device_display_name": "Fluffy Matrix Client" + } +); + +matrix.connect( + newToken: loginResp["token"], + newUserID: loginResp["user_id"], + newHomeserver: matrix.homeserver, + newDeviceName: "Fluffy Matrix Client", + newDeviceID: loginResp["device_id"], + newMatrixVersions: ["r0.4.0"], + newLazyLoadMembers: false +); +``` + +4. Send a message to a Room: + +```dart +final resp = await jsonRequest( + type: "PUT", + action: "/r0/rooms/!fjd823j:example.com/send/m.room.message/$txnId", + data: { + "msgtype": "m.text", + "body": "hello" + } +); +``` \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ba75c69 --- /dev/null +++ b/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8fa53a --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# famedlysdk + +A new Flutter package. + +## Getting Started + +This project is a starting point for a Dart +[package](https://flutter.dev/developing-packages/), +a library module containing code that can be shared easily across +multiple Flutter or Dart projects. + +For help getting started with Flutter, view our +[online documentation](https://flutter.dev/docs), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/lib/famedlysdk.dart b/lib/famedlysdk.dart new file mode 100644 index 0000000..e47eb0b --- /dev/null +++ b/lib/famedlysdk.dart @@ -0,0 +1,13 @@ +library famedlysdk; + +export 'package:famedlysdk/src/responses/ErrorResponse.dart'; +export 'package:famedlysdk/src/sync/RoomUpdate.dart'; +export 'package:famedlysdk/src/sync/EventUpdate.dart'; +export 'package:famedlysdk/src/utils/ChatTime.dart'; +export 'package:famedlysdk/src/utils/MxContent.dart'; +export 'package:famedlysdk/src/Client.dart'; +export 'package:famedlysdk/src/Connection.dart'; +export 'package:famedlysdk/src/Event.dart'; +export 'package:famedlysdk/src/Room.dart'; +export 'package:famedlysdk/src/Store.dart'; +export 'package:famedlysdk/src/User.dart'; \ No newline at end of file diff --git a/lib/src/Client.dart b/lib/src/Client.dart new file mode 100644 index 0000000..ea991d8 --- /dev/null +++ b/lib/src/Client.dart @@ -0,0 +1,169 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:core'; +import 'package:flutter/material.dart'; +import 'responses/ErrorResponse.dart'; +import 'Connection.dart'; +import 'Store.dart'; + +/// Represents a Matrix connection to communicate with a +/// [Matrix](https://matrix.org) homeserver and is the entry point for this +/// SDK. +class Client { + + /// Handles the connection for this client. + Connection connection; + + /// Optional persistent store for all data. + Store store; + + Client(this.clientName) { + connection = Connection(this); + + if (this.clientName != "testclient") + store = Store(this); + connection.onLoginStateChanged.stream.listen((loginState) { + print("LoginState: ${loginState.toString()}"); + }); + } + + /// The required name for this client. + final String clientName; + + /// The homeserver this client is communicating with. + String homeserver; + + /// The Matrix ID of the current logged user. + String userID; + + /// This is the access token for the matrix client. When it is undefined, then + /// the user needs to sign in first. + String accessToken; + + /// This points to the position in the synchronization history. + String prevBatch; + + /// The device ID is an unique identifier for this device. + String deviceID; + + /// The device name is a human readable identifier for this device. + String deviceName; + + /// Which version of the matrix specification does this server support? + List matrixVersions; + + /// Wheither the server supports lazy load members. + bool lazyLoadMembers = false; + + /// Returns the current login state. + bool isLogged() => accessToken != null; + + /// Checks the supported versions of the Matrix protocol and the supported + /// login types. Returns false if the server is not compatible with the + /// client. Automatically sets [matrixVersions] and [lazyLoadMembers]. + Future checkServer(serverUrl) async { + homeserver = serverUrl; + + final versionResp = + await connection.jsonRequest(type: "GET", action: "/client/versions"); + if (versionResp is ErrorResponse) { + connection.onError.add(ErrorResponse(errcode: "NO_RESPONSE", error: "")); + return false; + } + + final List versions = List.from(versionResp["versions"]); + + if (versions == null) { + connection.onError.add(ErrorResponse(errcode: "NO_RESPONSE", error: "")); + return false; + } + + for (int i = 0; i < versions.length; i++) { + if (versions[i] == "r0.4.0") + break; + else if (i == versions.length - 1) { + connection.onError.add(ErrorResponse(errcode: "NO_SUPPORT", error: "")); + return false; + } + } + + matrixVersions = versions; + + if (versionResp.containsKey("unstable_features") && + versionResp["unstable_features"].containsKey("m.lazy_load_members")) { + lazyLoadMembers = versionResp["unstable_features"]["m.lazy_load_members"] + ? true + : false; + } + + final loginResp = + await connection.jsonRequest(type: "GET", action: "/client/r0/login"); + if (loginResp is ErrorResponse) { + connection.onError.add(loginResp); + return false; + } + + final List flows = loginResp["flows"]; + + for (int i = 0; i < flows.length; i++) { + if (flows[i].containsKey("type") && + flows[i]["type"] == "m.login.password") + break; + else if (i == flows.length - 1) { + connection.onError.add(ErrorResponse(errcode: "NO_SUPPORT", error: "")); + return false; + } + } + + return true; + } + + /// Handles the login and allows the client to call all APIs which require + /// authentication. Returns false if the login was not successful. + Future login(String username, String password) async { + + final loginResp = + await connection.jsonRequest(type: "POST", action: "/client/r0/login", data: { + "type": "m.login.password", + "user": username, + "identifier": { + "type": "m.id.user", + "user": username, + }, + "password": password, + "initial_device_display_name": "Famedly Talk" + }); + + if (loginResp is ErrorResponse) { + connection.onError.add(loginResp); + return false; + } + + final userID = loginResp["user_id"]; + final accessToken = loginResp["access_token"]; + if (userID == null || accessToken == null) { + connection.onError.add(ErrorResponse(errcode: "NO_SUPPORT", error: "")); + } + + await connection.connect( + newToken: accessToken, + newUserID: userID, + newHomeserver: homeserver, + newDeviceName: "", + newDeviceID: "", + newMatrixVersions: matrixVersions, + newLazyLoadMembers: lazyLoadMembers); + return true; + } + + /// Sends a logout command to the homeserver and clears all local data, + /// including all persistent data from the store. + Future logout() async { + final dynamic resp = + await connection.jsonRequest(type: "POST", action: "/client/r0/logout/all"); + if (resp == null) return; + + await connection.clear(); + } + +} diff --git a/lib/src/Connection.dart b/lib/src/Connection.dart new file mode 100644 index 0000000..74fd489 --- /dev/null +++ b/lib/src/Connection.dart @@ -0,0 +1,415 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:core'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'responses/ErrorResponse.dart'; +import 'sync/EventUpdate.dart'; +import 'sync/RoomUpdate.dart'; +import 'Client.dart'; + +/// Represents a Matrix connection to communicate with a +/// [Matrix](https://matrix.org) homeserver. +class Connection { + final Client client; + + Connection(this.client) { + WidgetsBinding.instance + .addObserver(_LifecycleEventHandler(resumeCallBack: () { + _sync(); + })); + } + + String get _syncFilters => + "{\"room\":{\"state\":{\"lazy_load_members\":${client.lazyLoadMembers ? "1" : "0"}}}"; + + /// Handles the connection to the Matrix Homeserver. You can change this to a + /// MockClient for testing. + http.Client httpClient = http.Client(); + + /// The newEvent signal is the most important signal in this concept. Every time + /// the app receives a new synchronization, this event is called for every signal + /// to update the GUI. For example, for a new message, it is called: + /// onRoomEvent( "m.room.message", "!chat_id:server.com", "timeline", {sender: "@bob:server.com", body: "Hello world"} ) + final StreamController onEvent = + new StreamController.broadcast(); + + /// Outside of the events there are updates for the global chat states which + /// are handled by this signal: + final StreamController onRoomUpdate = + new StreamController.broadcast(); + + /// Called when the login state e.g. user gets logged out. + final StreamController onLoginStateChanged = + new StreamController.broadcast(); + + /// Synchronization erros are coming here. + final StreamController onError = + new StreamController.broadcast(); + + /// This is called once, when the first sync has received. + final StreamController onFirstSync = new StreamController.broadcast(); + + /// When a new sync response is coming in, this gives the complete payload. + final StreamController onSync = new StreamController.broadcast(); + + /// Matrix synchronisation is done with https long polling. This needs a + /// timeout which is usually 30 seconds. + int syncTimeoutSec = 30; + + /// How long should the app wait until it retrys the synchronisation after + /// an error? + int syncErrorTimeoutSec = 3; + + /// Sets the user credentials and starts the synchronisation. + /// + /// Before you can connect you need at least an [accessToken], a [homeserver], + /// a [userID], a [deviceID], and a [deviceName]. + /// + /// You get this informations + /// by logging in to your Matrix account, using the [login API](https://matrix.org/docs/spec/client_server/r0.4.0.html#post-matrix-client-r0-login). + /// + /// To log in you can use [jsonRequest()] after you have set the [homeserver] + /// to a valid url. For example: + /// + /// ``` + /// final resp = await matrix + /// .jsonRequest(type: "POST", action: "/client/r0/login", data: { + /// "type": "m.login.password", + /// "user": "test", + /// "password": "1234", + /// "initial_device_display_name": "Fluffy Matrix Client" + /// }); + /// ``` + /// + /// Returns: + /// + /// ``` + /// { + /// "user_id": "@cheeky_monkey:matrix.org", + /// "access_token": "abc123", + /// "device_id": "GHTYAJCE" + /// } + /// ``` + /// + /// Sends [LoginState.logged] to [onLoginStateChanged]. + void connect( + {@required String newToken, + @required String newHomeserver, + @required String newUserID, + @required String newDeviceName, + @required String newDeviceID, + List newMatrixVersions, + bool newLazyLoadMembers, + String newPrevBatch}) async { + client.accessToken = newToken; + client.homeserver = newHomeserver; + client.userID = newUserID; + client.deviceID = newDeviceID; + client.deviceName = newDeviceName; + client.matrixVersions = newMatrixVersions; + client.lazyLoadMembers = newLazyLoadMembers; + client.prevBatch = newPrevBatch; + + client.store?.storeClient(); + + onLoginStateChanged.add(LoginState.logged); + + _sync(); + } + + /// Resets all settings and stops the synchronisation. + void clear() { + client.store?.clear(); + client.accessToken = client.homeserver = client.userID = client.deviceID = + client.deviceName = client.matrixVersions = + client.lazyLoadMembers = client.prevBatch = null; + onLoginStateChanged.add(LoginState.loggedOut); + } + + /// Used for all Matrix json requests using the [c2s API](https://matrix.org/docs/spec/client_server/r0.4.0.html). + /// + /// You must first call [this.connect()] or set [this.homeserver] before you can use + /// this! For example to send a message to a Matrix room with the id + /// '!fjd823j:example.com' you call: + /// + /// ``` + /// final resp = await jsonRequest( + /// type: "PUT", + /// action: "/r0/rooms/!fjd823j:example.com/send/m.room.message/$txnId", + /// data: { + /// "msgtype": "m.text", + /// "body": "hello" + /// } + /// ); + /// ``` + /// + Future jsonRequest( + {String type, String action, dynamic data = "", int timeout}) async { + if (client.isLogged() == false && client.homeserver == null) + throw ("No homeserver specified."); + if (timeout == null) timeout = syncTimeoutSec; + if (!(data is String)) data = jsonEncode(data); + + final url = "${client.homeserver}/_matrix${action}"; + + Map headers = { + "Content-type": "application/json", + }; + if (client.isLogged()) + headers["Authorization"] = "Bearer ${client.accessToken}"; + + var resp; + try { + switch (type) { + case "GET": + resp = await httpClient + .get(url, headers: headers) + .timeout(Duration(seconds: timeout)); + break; + case "POST": + resp = await httpClient + .post(url, body: data, headers: headers) + .timeout(Duration(seconds: timeout)); + break; + case "PUT": + resp = await httpClient + .put(url, body: data, headers: headers) + .timeout(Duration(seconds: timeout)); + break; + case "DELETE": + resp = await httpClient + .delete(url, headers: headers) + .timeout(Duration(seconds: timeout)); + break; + } + } on TimeoutException catch (_) { + return ErrorResponse( + error: "No connection possible...", errcode: "TIMEOUT"); + } catch (e) { + return ErrorResponse( + error: "No connection possible...", errcode: "NO_CONNECTION"); + } + + Map jsonResp; + try { + jsonResp = jsonDecode(resp.body) as Map; + } catch (e) { + return ErrorResponse( + error: "No connection possible...", errcode: "MALFORMED"); + } + if (jsonResp.containsKey("errcode") && jsonResp["errcode"] is String) { + if (jsonResp["errcode"] == "M_UNKNOWN_TOKEN") clear(); + return ErrorResponse.fromJson(jsonResp); + } + + return jsonResp; + } + + Future _syncRequest; + + Future _sync() async { + if (client.isLogged() == false) return; + + dynamic args = {}; + + String action = "/client/r0/sync?filters=${_syncFilters}"; + + if (client.prevBatch != null) { + action += "&timeout=30000"; + action += "&since=${client.prevBatch}"; + } + _syncRequest = jsonRequest(type: "GET", action: action); + final int hash = _syncRequest.hashCode; + final syncResp = await _syncRequest; + if (syncResp is ErrorResponse) { + onError.add(syncResp); + await Future.delayed(Duration(seconds: syncErrorTimeoutSec), () {}); + } else { + try { + if (client.store != null) + await client.store.transaction(() { + _handleSync(syncResp); + client.store.storePrevBatch(syncResp); + }); + else + await _handleSync(syncResp); + if (client.prevBatch == null) client.connection.onFirstSync.add(true); + client.prevBatch = syncResp["next_batch"]; + } catch (e) { + onError + .add(ErrorResponse(errcode: "CRITICAL_ERROR", error: e.toString())); + await Future.delayed(Duration(seconds: syncErrorTimeoutSec), () {}); + } + } + if (hash == _syncRequest.hashCode) _sync(); + } + + void _handleSync(dynamic sync) { + if (sync["rooms"] is Map) { + if (sync["rooms"]["join"] is Map) + _handleRooms(sync["rooms"]["join"], "join"); + if (sync["rooms"]["invite"] is Map) + _handleRooms(sync["rooms"]["invite"], "invite"); + if (sync["rooms"]["leave"] is Map) + _handleRooms(sync["rooms"]["leave"], "leave"); + } + if (sync["presence"] is Map && + sync["presence"]["events"] is List) { + _handleGlobalEvents(sync["presence"]["events"], "presence"); + } + if (sync["account_data"] is Map && + sync["account_data"]["events"] is List) { + _handleGlobalEvents(sync["account_data"]["events"], "account_data"); + } + if (sync["to_device"] is Map && + sync["to_device"]["events"] is List) { + _handleGlobalEvents(sync["to_device"]["events"], "to_device"); + } + onSync.add(sync); + } + + void _handleRooms(Map rooms, String membership) { + rooms.forEach((String id, dynamic room) async { + // calculate the notification counts, the limitedTimeline and prevbatch + num highlight_count = 0; + num notification_count = 0; + String prev_batch = ""; + bool limitedTimeline = false; + + if (room["unread_notifications"] is Map) { + if (room["unread_notifications"]["highlight_count"] is num) + highlight_count = room["unread_notifications"]["highlight_count"]; + if (room["unread_notifications"]["notification_count"] is num) + notification_count = + room["unread_notifications"]["notification_count"]; + } + + if (room["timeline"] is Map) { + if (room["timeline"]["limited"] is bool) + limitedTimeline = room["timeline"]["limited"]; + if (room["timeline"]["prev_batch"] is String) + prev_batch = room["timeline"]["prev_batch"]; + } + + RoomUpdate update = RoomUpdate( + id: id, + membership: membership, + notification_count: notification_count, + highlight_count: highlight_count, + limitedTimeline: limitedTimeline, + prev_batch: prev_batch, + ); + client.store?.storeRoomUpdate(update); + onRoomUpdate.add(update); + + /// Handle now all room events and save them in the database + if (room["state"] is Map && + room["state"]["events"] is List) + _handleRoomEvents(id, room["state"]["events"], "state"); + + if (room["invite_state"] is Map && + room["invite_state"]["events"] is List) + _handleRoomEvents( + id, room["invite_state"]["events"], "invite_state"); + + if (room["timeline"] is Map && + room["timeline"]["events"] is List) + _handleRoomEvents(id, room["timeline"]["events"], "timeline"); + + if (room["ephemetal"] is Map && + room["ephemetal"]["events"] is List) + _handleEphemerals(id, room["ephemetal"]["events"]); + + if (room["account_data"] is Map && + room["account_data"]["events"] is List) + _handleRoomEvents( + id, room["account_data"]["events"], "account_data"); + }); + } + + void _handleEphemerals(String id, List events) { + for (num i = 0; i < events.length; i++) { + if (!(events[i]["type"] is String && + events[i]["content"] is Map)) continue; + if (events[i]["type"] == "m.receipt") { + events[i]["content"].forEach((String e, dynamic value) { + if (!(events[i]["content"][e] is Map && + events[i]["content"][e]["m.read"] is Map)) + return; + events[i]["content"][e]["m.read"] + .forEach((String user, dynamic value) async { + if (!(events[i]["content"][e]["m.read"]["user"] + is Map && + events[i]["content"][e]["m.read"]["ts"] is num)) return; + + num timestamp = events[i]["content"][e]["m.read"]["ts"]; + + _handleEvent(events[i], id, "ephemeral"); + }); + }); + } else if (events[i]["type"] == "m.typing") { + if (!(events[i]["content"]["user_ids"] is List)) continue; + + List user_ids = events[i]["content"]["user_ids"]; + + /// If the user is typing, remove his id from the list of typing users + var ownTyping = user_ids.indexOf(client.userID); + if (ownTyping != -1) user_ids.removeAt(1); + + _handleEvent(events[i], id, "ephemeral"); + } + } + } + + void _handleRoomEvents( + String chat_id, List events, String type) { + for (num i = 0; i < events.length; i++) { + _handleEvent(events[i], chat_id, type); + } + } + + void _handleGlobalEvents(List events, String type) { + for (int i = 0; i < events.length; i++) + _handleEvent(events[i], type, type); + } + + void _handleEvent( + Map event, String roomID, String type) { + if (event["type"] is String && event["content"] is dynamic) { + EventUpdate update = EventUpdate( + eventType: event["type"], + roomID: roomID, + type: type, + content: event, + ); + client.store?.storeEventUpdate(update); + onEvent.add(update); + } + } +} + +class _LifecycleEventHandler extends WidgetsBindingObserver { + _LifecycleEventHandler({this.resumeCallBack, this.suspendingCallBack}); + + final _FutureVoidCallback resumeCallBack; + final _FutureVoidCallback suspendingCallBack; + + @override + Future didChangeAppLifecycleState(AppLifecycleState state) async { + switch (state) { + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + case AppLifecycleState.suspending: + await suspendingCallBack(); + break; + case AppLifecycleState.resumed: + await resumeCallBack(); + break; + } + } +} + +typedef _FutureVoidCallback = Future Function(); + +enum LoginState { logged, loggedOut } diff --git a/lib/src/Event.dart b/lib/src/Event.dart new file mode 100644 index 0000000..70cf258 --- /dev/null +++ b/lib/src/Event.dart @@ -0,0 +1,120 @@ +import 'dart:convert'; +import './User.dart'; +import 'package:famedlysdk/src/utils/ChatTime.dart'; +import 'package:famedlysdk/src/Client.dart'; + +class Event { + final String id; + final String roomID; + final ChatTime time; + final User sender; + final User stateKey; + final String environment; + final String text; + final String formattedText; + final int status; + final Map content; + + const Event(this.id, this.sender, this.time,{ + this.roomID, + this.stateKey, + this.text, + this.formattedText, + this.status = 2, + this.environment = "timeline", + this.content, + }); + + String getBody () => formattedText ?? text ?? "*** Unable to parse Content ***"; + + EventTypes get type { + switch (environment) { + case "m.room.avatar": return EventTypes.RoomAvatar; + case "m.room.name": return EventTypes.RoomName; + case "m.room.topic": return EventTypes.RoomTopic; + case "m.room.Aliases": return EventTypes.RoomAliases; + case "m.room.canonical_alias": return EventTypes.RoomCanonicalAlias; + case "m.room.create": return EventTypes.RoomCreate; + case "m.room.join_rules": return EventTypes.RoomJoinRules; + case "m.room.member": return EventTypes.RoomMember; + case "m.room.power_levels": return EventTypes.RoomPowerLevels; + case "m.room.message": + switch(content["msgtype"] ?? "m.text") { + case "m.text": return EventTypes.Text; + case "m.notice": return EventTypes.Notice; + case "m.emote": return EventTypes.Emote; + case "m.image": return EventTypes.Image; + case "m.video": return EventTypes.Video; + case "m.audio": return EventTypes.Audio; + case "m.file": return EventTypes.File; + case "m.location": return EventTypes.Location; + } + } + + } + + static Event fromJson(Map jsonObj) { + Map content; + try { + content = json.decode(jsonObj["content_json"]); + } catch(e) { + print("jsonObj decode of event content failed: ${e.toString()}"); + content = {}; + } + return Event( + jsonObj["id"], + User.fromJson(jsonObj), + ChatTime(jsonObj["origin_server_ts"]), + stateKey: User(jsonObj["state_key"]), + environment: jsonObj["type"], + text: jsonObj["content_body"], + status: jsonObj["status"], + content: content, + ); + } + + static Future> getEventList(Client matrix, String roomID) async{ + List> eventRes = await matrix.store.db.rawQuery( + "SELECT * " + + " FROM Events events, Memberships memberships " + + " WHERE events.chat_id=?" + + " AND events.sender=memberships.matrix_id " + + " GROUP BY events.id " + + " ORDER BY origin_server_ts DESC", + [roomID]); + + List eventList = []; + + for (num i = 0; i < eventRes.length; i++) + eventList.add(Event.fromJson(eventRes[i])); + return eventList; + } + +} + +enum EventTypes { + Text, + Emote, + Notice, + Image, + Video, + Audio, + File, + Location, + RoomAliases, + RoomCanonicalAlias, + RoomCreate, + RoomJoinRules, + RoomMember, + RoomPowerLevels, + RoomName, + RoomTopic, + RoomAvatar, +} + +final Map StatusTypes = { + "ERROR": -1, + "SENDING": 0, + "SENT": 1, + "RECEIVED": 2, +}; \ No newline at end of file diff --git a/lib/src/Room.dart b/lib/src/Room.dart new file mode 100644 index 0000000..43b33a2 --- /dev/null +++ b/lib/src/Room.dart @@ -0,0 +1,197 @@ +import 'dart:convert'; +import 'package:famedlysdk/src/Client.dart'; +import 'package:famedlysdk/src/utils/ChatTime.dart'; +import 'package:famedlysdk/src/utils/MxContent.dart'; +import 'package:famedlysdk/src/responses/ErrorResponse.dart'; +import './User.dart'; +import 'package:famedlysdk/src/Event.dart'; + +/// FIXME use actual Matrix Stuff. This is a placeholder +class Room { + final String roomID; + String name; + String lastMessage; + MxContent avatar; + ChatTime timeCreated; + int notificationCount; + int highlightCount; + String topic; + User user; + final Client matrix; + List events = []; + + Room({ + this.roomID, + this.name, + this.lastMessage, + this.avatar, + this.timeCreated, + this.notificationCount, + this.highlightCount, + this.topic, + this.user, + this.matrix, + this.events, + }); + + String get status { + if (this.user != null) { + return this.user.status; + } + return this.topic; + } + + Future setName(String newName) async{ + dynamic res = await matrix.connection.jsonRequest( + type: "PUT", + action: + "/client/r0/rooms/${roomID}/send/m.room.name/${new DateTime.now()}", + data: {"name": newName}); + if (res is ErrorResponse) matrix.connection.onError.add(res); + return res; + } + + Future setDescription(String newName) async{ + dynamic res = await matrix.connection.jsonRequest( + type: "PUT", + action: + "/client/r0/rooms/${roomID}/send/m.room.topic/${new DateTime.now()}", + data: {"topic": newName}); + if (res is ErrorResponse) matrix.connection.onError.add(res); + return res; + } + + Stream> get eventsStream { + return Stream>.fromIterable(Iterable>.generate( + this.events.length, (int index) => this.events)).asBroadcastStream(); + } + + Future sendText(String message) async { + dynamic res = await matrix.connection.jsonRequest( + type: "PUT", + action: + "/client/r0/rooms/${roomID}/send/m.room.message/${new DateTime.now()}", + data: {"msgtype": "m.text", "body": message}); + if (res["errcode"] == "M_LIMIT_EXCEEDED") matrix.connection.onError.add(res["error"]); + } + + Future leave() async { + dynamic res = await matrix.connection.jsonRequest( + type: "POST", + action: + "/client/r0/rooms/${roomID}/leave"); + if (res is ErrorResponse) matrix.connection.onError.add(res); + return res; + } + + Future forget() async { + dynamic res = await matrix.connection.jsonRequest( + type: "POST", + action: + "/client/r0/rooms/${roomID}/forget"); + if (res is ErrorResponse) matrix.connection.onError.add(res); + return res; + } + + Future kick(String userID) async { + dynamic res = await matrix.connection.jsonRequest( + type: "POST", + action: + "/client/r0/rooms/${roomID}/kick", + data: {"user_id": userID}); + if (res is ErrorResponse) matrix.connection.onError.add(res); + return res; + } + + Future ban(String userID) async { + dynamic res = await matrix.connection.jsonRequest( + type: "POST", + action: + "/client/r0/rooms/${roomID}/ban", + data: {"user_id": userID}); + if (res is ErrorResponse) matrix.connection.onError.add(res); + return res; + } + + Future unban(String userID) async { + dynamic res = await matrix.connection.jsonRequest( + type: "POST", + action: + "/client/r0/rooms/${roomID}/unban", + data: {"user_id": userID}); + if (res is ErrorResponse) matrix.connection.onError.add(res); + return res; + } + + Future invite(String userID) async { + dynamic res = await matrix.connection.jsonRequest( + type: "POST", + action: + "/client/r0/rooms/${roomID}/invite", + data: {"user_id": userID}); + if (res is ErrorResponse) matrix.connection.onError.add(res); + return res; + } + + static Future getRoomFromTableRow( + Map row, Client matrix) async { + String name = row["topic"]; + if (name == "") name = await matrix.store.getChatNameFromMemberNames(row["id"]); + + String content_body = row["content_body"]; + if (content_body == null || content_body == "") + content_body = "Keine vorhergehenden Nachrichten"; + + String avatarMxcUrl = row["avatar_url"]; + + if (avatarMxcUrl == "") + avatarMxcUrl = await matrix.store.getAvatarFromSingleChat(row["id"]); + + return Room( + roomID: row["id"], + name: name, + lastMessage: content_body, + avatar: MxContent(avatarMxcUrl), + timeCreated: ChatTime(row["origin_server_ts"]), + notificationCount: row["notification_count"], + highlightCount: row["highlight_count"], + topic: "", + matrix: matrix, + events: [], + ); + } + + static Future getRoomById(String id, Client matrix) async { + List> res = + await matrix.store.db.rawQuery("SELECT * FROM Chats WHERE id=?", [id]); + if (res.length != 1) return null; + return getRoomFromTableRow(res[0], matrix); + } + + static Future loadRoomEvents(String id, Client matrix) async { + Room room = await Room.getRoomById(id, matrix); + room.events = await Event.getEventList(matrix, id); + return room; + } + + Future> requestParticipants(Client matrix) async { + List participants = []; + + dynamic res = await matrix.connection.jsonRequest( + type: "GET", action: "/client/r0/rooms/${roomID}/members"); + if (res is ErrorResponse || !(res["chunk"] is List)) + return participants; + + for (num i = 0; i < res["chunk"].length; i++) { + User newUser = User(res["chunk"][i]["state_key"], + displayName: res["chunk"][i]["content"]["displayname"] ?? "", + status: res["chunk"][i]["content"]["membership"] ?? "", + directChatRoomId: "", + avatar_url: + MxContent(res["chunk"][i]["content"]["avatar_url"] ?? "")); + if (newUser.status != "leave") participants.add(newUser); + } + + return participants; + } +} diff --git a/lib/src/Store.dart b/lib/src/Store.dart new file mode 100644 index 0000000..9bfc91a --- /dev/null +++ b/lib/src/Store.dart @@ -0,0 +1,516 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:core'; +import 'package:sqflite/sqflite.dart'; +import 'sync/EventUpdate.dart'; +import 'sync/RoomUpdate.dart'; +import 'package:path/path.dart' as p; +import 'Client.dart'; +import 'User.dart'; +import 'Room.dart'; +import 'Connection.dart'; + +/// Represents a Matrix connection to communicate with a +/// [Matrix](https://matrix.org) homeserver. +class Store { + + final Client client; + + Store(this.client) { + _init(); + } + + Database _db; + + /// SQLite database for all persistent data. It is recommended to extend this + /// SDK instead of writing direct queries to the database. + Database get db => _db; + + _init() async{ + var databasePath = await getDatabasesPath(); + String path = p.join(databasePath, "FluffyMatrix.db"); + _db = await openDatabase(path, version: 2, + onCreate: (Database db, int version) async { + // When creating the db, create the table + await db.execute(ClientScheme); + await db.execute(RoomScheme); + await db.execute(MemberScheme); + await db.execute(EventScheme); + }); + + List list = await _db + .rawQuery("SELECT * FROM Clients WHERE client=?", [client.clientName]); + if (list.length == 1) { + var clientList = list[0]; + client.connection.connect( + newToken: clientList["token"], + newHomeserver: clientList["homeserver"], + newUserID: clientList["matrix_id"], + newDeviceID: clientList["device_id"], + newDeviceName: clientList["device_name"], + newLazyLoadMembers: clientList["lazy_load_members"] == 1, + newMatrixVersions: clientList["matrix_versions"].toString().split(","), + newPrevBatch: clientList["prev_batch"], + ); + print("Restore client credentials of ${client.userID}"); + } else + client.connection.onLoginStateChanged.add(LoginState.loggedOut); + } + + Future queryPrevBatch() async{ + List list = await txn.rawQuery("SELECT prev_batch FROM Clients WHERE client=?", [client.clientName]); + return list[0]["prev_batch"]; + } + + /// Will be automatically called when the client is logged in successfully. + Future storeClient() async{ + await _db + .rawInsert('INSERT OR IGNORE INTO Clients VALUES(?,?,?,?,?,?,?,?,?)', [ + client.clientName, + client.accessToken, + client.homeserver, + client.userID, + client.deviceID, + client.deviceName, + client.prevBatch, + client.matrixVersions.join(","), + client.lazyLoadMembers, + ]); + return; + } + + /// Clears all tables from the database. + Future clear() async{ + await _db.rawDelete("DELETE FROM Clients WHERE client=?", [client.clientName]); + await _db.rawDelete("DELETE FROM Chats"); + await _db.rawDelete("DELETE FROM Memberships"); + await _db.rawDelete("DELETE FROM Events"); + return; + } + + Transaction txn; + + Future transaction(Future queries()) async{ + return client.store.db.transaction((txnObj) async { + txn = txnObj; + await queries(); + }); + } + + /// Will be automatically called on every synchronisation. Must be called inside of + // /// [transaction]. + Future storePrevBatch(dynamic sync) { + txn.rawUpdate("UPDATE Clients SET prev_batch=? WHERE client=?", + [client.prevBatch, client.clientName]); + } + + /// Stores a RoomUpdate object in the database. Must be called inside of + /// [transaction]. + Future storeRoomUpdate(RoomUpdate roomUpdate) { + // Insert the chat into the database if not exists + txn.rawInsert( + "INSERT OR IGNORE INTO Chats " + + "VALUES(?, ?, '', 0, 0, 0, '', '', '', 0, '', '', '', '', '', '', 0, 50, 50, 0, 50, 50, 0, 50, 100, 50, 50, 50, 100) ", + [roomUpdate.id, roomUpdate.membership]); + + // Update the notification counts and the limited timeline boolean + txn.rawUpdate( + "UPDATE Chats SET highlight_count=?, notification_count=?, membership=?, limitedTimeline=? WHERE id=? ", + [ + roomUpdate.highlight_count, + roomUpdate.notification_count, + roomUpdate.membership, + roomUpdate.limitedTimeline, + roomUpdate.id + ]); + + // Is the timeline limited? Then all previous messages should be + // removed from the database! + if (roomUpdate.limitedTimeline) { + txn.rawDelete("DELETE FROM Events WHERE chat_id=?", [roomUpdate.id]); + txn.rawUpdate("UPDATE Chats SET prev_batch=? WHERE id=?", + [roomUpdate.prev_batch, roomUpdate.id]); + } + } + + /// Stores an EventUpdate object in the database. Must be called inside of + // /// [transaction]. + Future storeEventUpdate(EventUpdate eventUpdate) { + dynamic eventContent = eventUpdate.content; + String type = eventUpdate.type; + String chat_id = eventUpdate.roomID; + + if (type == "timeline" || type == "history") { + // calculate the status + num status = 2; + // Make unsigned part of the content + if (eventContent["unsigned"] is Map) + eventContent["content"]["unsigned"] = eventContent["unsigned"]; + + // Get the state_key for m.room.member events + String state_key = ""; + if (eventContent["state_key"] is String) { + state_key = eventContent["state_key"]; + } + + // Save the event in the database + + txn.rawInsert( + "INSERT OR REPLACE INTO Events VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)", [ + eventContent["event_id"], + chat_id, + eventContent["origin_server_ts"], + eventContent["sender"], + state_key, + eventContent["content"]["body"], + eventContent["type"], + json.encode(eventContent["content"]), + status + ]); + } + + if (type == "history") return null; + + switch (eventUpdate.eventType) { + case "m.receipt": + if (eventContent["user"] == client.userID) { + txn.rawUpdate("UPDATE Chats SET unread=? WHERE id=?", + [eventContent["ts"], chat_id]); + } else { + // Mark all previous received messages as seen + txn.rawUpdate( + "UPDATE Events SET status=3 WHERE origin_server_ts<=? AND chat_id=? AND status=2", + [eventContent["ts"], chat_id]); + } + break; + // This event means, that the name of a room has been changed, so + // it has to be changed in the database. + case "m.room.name": + txn.rawUpdate("UPDATE Chats SET topic=? WHERE id=?", + [eventContent["content"]["name"], chat_id]); + break; + // This event means, that the topic of a room has been changed, so + // it has to be changed in the database + case "m.room.topic": + txn.rawUpdate("UPDATE Chats SET description=? WHERE id=?", + [eventContent["content"]["topic"], chat_id]); + break; + // This event means, that the topic of a room has been changed, so + // it has to be changed in the database + case "m.room.history_visibility": + txn.rawUpdate("UPDATE Chats SET history_visibility=? WHERE id=?", + [eventContent["content"]["history_visibility"], chat_id]); + break; + // This event means, that the topic of a room has been changed, so + // it has to be changed in the database + case "m.room.redaction": + txn.rawDelete( + "DELETE FROM Events WHERE id=?", [eventContent["redacts"]]); + break; + // This event means, that the topic of a room has been changed, so + // it has to be changed in the database + case "m.room.guest_access": + txn.rawUpdate("UPDATE Chats SET guest_access=? WHERE id=?", + [eventContent["content"]["guest_access"], chat_id]); + break; + // This event means, that the topic of a room has been changed, so + // it has to be changed in the database + case "m.room.join_rules": + txn.rawUpdate("UPDATE Chats SET join_rules=? WHERE id=?", + [eventContent["content"]["join_rule"], chat_id]); + break; + // This event means, that the avatar of a room has been changed, so + // it has to be changed in the database + case "m.room.avatar": + txn.rawUpdate("UPDATE Chats SET avatar_url=? WHERE id=?", + [eventContent["content"]["url"], chat_id]); + break; + // This event means, that the aliases of a room has been changed, so + // it has to be changed in the database + case "m.fully_read": + txn.rawUpdate("UPDATE Chats SET fully_read=? WHERE id=?", + [eventContent["content"]["event_id"], chat_id]); + break; + // This event means, that someone joined the room, has left the room + // or has changed his nickname + case "m.room.member": + String membership = eventContent["content"]["membership"]; + String state_key = eventContent["state_key"]; + String insertDisplayname = ""; + String insertAvatarUrl = ""; + if (eventContent["content"]["displayname"] is String) { + insertDisplayname = eventContent["content"]["displayname"]; + } + if (eventContent["content"]["avatar_url"] is String) { + insertAvatarUrl = eventContent["content"]["avatar_url"]; + } + + // Update membership table + txn.rawInsert("INSERT OR IGNORE INTO Memberships VALUES(?,?,?,?,?,0)", [ + chat_id, + state_key, + insertDisplayname, + insertAvatarUrl, + membership + ]); + String queryStr = "UPDATE Memberships SET membership=?"; + List queryArgs = [membership]; + + if (eventContent["content"]["displayname"] is String) { + queryStr += " , displayname=?"; + queryArgs.add(eventContent["content"]["displayname"]); + } + if (eventContent["content"]["avatar_url"] is String) { + queryStr += " , avatar_url=?"; + queryArgs.add(eventContent["content"]["avatar_url"]); + } + + queryStr += " WHERE matrix_id=? AND chat_id=?"; + queryArgs.add(state_key); + queryArgs.add(chat_id); + txn.rawUpdate(queryStr, queryArgs); + break; + // This event changes the permissions of the users and the power levels + case "m.room.power_levels": + String query = "UPDATE Chats SET "; + if (eventContent["content"]["ban"] is num) + query += ", power_ban=" + eventContent["content"]["ban"].toString(); + if (eventContent["content"]["events_default"] is num) + query += ", power_events_default=" + + eventContent["content"]["events_default"].toString(); + if (eventContent["content"]["state_default"] is num) + query += ", power_state_default=" + + eventContent["content"]["state_default"].toString(); + if (eventContent["content"]["redact"] is num) + query += + ", power_redact=" + eventContent["content"]["redact"].toString(); + if (eventContent["content"]["invite"] is num) + query += + ", power_invite=" + eventContent["content"]["invite"].toString(); + if (eventContent["content"]["kick"] is num) + query += ", power_kick=" + eventContent["content"]["kick"].toString(); + if (eventContent["content"]["user_default"] is num) + query += ", power_user_default=" + + eventContent["content"]["user_default"].toString(); + if (eventContent["content"]["events"] is Map) { + if (eventContent["content"]["events"]["m.room.avatar"] is num) + query += ", power_event_avatar=" + + eventContent["content"]["events"]["m.room.avatar"].toString(); + if (eventContent["content"]["events"]["m.room.history_visibility"] + is num) + query += ", power_event_history_visibility=" + + eventContent["content"]["events"]["m.room.history_visibility"] + .toString(); + if (eventContent["content"]["events"]["m.room.canonical_alias"] + is num) + query += ", power_event_canonical_alias=" + + eventContent["content"]["events"]["m.room.canonical_alias"] + .toString(); + if (eventContent["content"]["events"]["m.room.aliases"] is num) + query += ", power_event_aliases=" + + eventContent["content"]["events"]["m.room.aliases"].toString(); + if (eventContent["content"]["events"]["m.room.name"] is num) + query += ", power_event_name=" + + eventContent["content"]["events"]["m.room.name"].toString(); + if (eventContent["content"]["events"]["m.room.power_levels"] is num) + query += ", power_event_power_levels=" + + eventContent["content"]["events"]["m.room.power_levels"] + .toString(); + } + if (query != "UPDATE Chats SET ") { + query = query.replaceFirst(",", ""); + txn.rawUpdate(query + " WHERE id=?", [chat_id]); + } + + // Set the users power levels: + if (eventContent["content"]["users"] is Map) { + eventContent["content"]["users"] + .forEach((String user, dynamic value) async { + num power_level = eventContent["content"]["users"][user]; + txn.rawUpdate( + "UPDATE Memberships SET power_level=? WHERE matrix_id=? AND chat_id=?", + [power_level, user, chat_id]); + txn.rawInsert( + "INSERT OR IGNORE INTO Memberships VALUES(?, ?, '', '', ?, ?)", + [chat_id, user, "unknown", power_level]); + }); + } + break; + } + } + + /// Returns a User object by a given Matrix ID and a Room ID. + Future getUser( + {String matrixID, String roomID}) async { + List> res = await db.rawQuery( + "SELECT * FROM Memberships WHERE matrix_id=? AND chat_id=?", + [matrixID, roomID]); + if (res.length != 1) return null; + return User.fromJson(res[0]); + } + + /// Loads all Users in the database to provide a contact list. + Future> loadContacts() async { + List> res = await db.rawQuery( + "SELECT * FROM Memberships WHERE matrix_id!=? GROUP BY matrix_id ORDER BY displayname", + [client.userID]); + List userList = []; + for (int i = 0; i < res.length; i++) userList.add(User.fromJson(res[i])); + return userList; + } + + /// Returns all users of a room by a given [roomID]. + Future> loadParticipants(String roomID) async { + List> res = await db.rawQuery( + "SELECT * " + + " FROM Memberships " + + " WHERE chat_id=? " + + " AND membership='join'", + [roomID]); + + List participants = []; + + for (num i = 0; i < res.length; i++) { + participants.add(User.fromJson(res[i])); + } + + return participants; + } + + /// Returns all rooms, the client is participating. Excludes left rooms. + Future> getRoomList() async { + List> res = await db.rawQuery( + "SELECT rooms.id, rooms.topic, rooms.membership, rooms.notification_count, rooms.highlight_count, rooms.avatar_url, rooms.unread, " + + " events.id AS eventsid, origin_server_ts, events.content_body, events.sender, events.state_key, events.content_json, events.type " + + " FROM Chats rooms LEFT JOIN Events events " + + " ON rooms.id=events.chat_id " + + " WHERE rooms.membership!='leave' " + + " GROUP BY rooms.id " + + " ORDER BY origin_server_ts DESC "); + List roomList = []; + for (num i = 0; i < res.length; i++) { + try { + Room room = await Room.getRoomFromTableRow(res[i], client); + roomList.add(room); + } catch (e) { + print(e.toString()); + } + } + return roomList; + } + + /// Calculates and returns an avatar for a direct chat by a given [roomID]. + Future getAvatarFromSingleChat( + String roomID) async { + String avatarStr = ""; + List> res = await db.rawQuery( + "SELECT avatar_url FROM Memberships " + + " WHERE Memberships.chat_id=? " + + " AND (Memberships.membership='join' OR Memberships.membership='invite') " + + " AND Memberships.matrix_id!=? ", + [roomID, client.userID]); + if (res.length == 1) avatarStr = res[0]["avatar_url"]; + return avatarStr; + } + + /// Calculates a chat name for a groupchat without a name. The chat name will + /// be the name of all users (excluding the user of this client) divided by + /// ','. + Future getChatNameFromMemberNames( + String roomID) async { + String displayname = 'Empty chat'; + List> rs = await db.rawQuery( + "SELECT Memberships.displayname, Memberships.matrix_id, Memberships.membership FROM Memberships " + + " WHERE Memberships.chat_id=? " + + " AND (Memberships.membership='join' OR Memberships.membership='invite') " + + " AND Memberships.matrix_id!=? ", + [roomID, client.userID]); + if (rs.length > 0) { + displayname = ""; + for (var i = 0; i < rs.length; i++) { + String username = rs[i]["displayname"]; + if (username == "" || username == null) username = rs[i]["matrix_id"]; + if (rs[i]["state_key"] != client.userID) displayname += username + ", "; + } + if (displayname == "" || displayname == null) + displayname = 'Empty chat'; + else + displayname = displayname.substring(0, displayname.length - 2); + } + return displayname; + } + + /// The database sheme for the Client class. + static final String ClientScheme = 'CREATE TABLE IF NOT EXISTS Clients(' + + 'client TEXT PRIMARY KEY, ' + + 'token TEXT, ' + + 'homeserver TEXT, ' + + 'matrix_id TEXT, ' + + 'device_id TEXT, ' + + 'device_name TEXT, ' + + 'prev_batch TEXT, ' + + 'matrix_versions TEXT, ' + + 'lazy_load_members INTEGER, ' + + 'UNIQUE(client))'; + /// The database sheme for the Room class. + static final String RoomScheme = 'CREATE TABLE IF NOT EXISTS Chats(' + + 'id TEXT PRIMARY KEY, ' + + 'membership TEXT, ' + + 'topic TEXT, ' + + 'highlight_count INTEGER, ' + + 'notification_count INTEGER, ' + + 'limitedTimeline INTEGER, ' + + 'prev_batch TEXT, ' + + 'avatar_url TEXT, ' + + 'draft TEXT, ' + + 'unread INTEGER, ' + // Timestamp of when the user has last read the chat + 'fully_read TEXT, ' + // ID of the fully read marker event + 'description TEXT, ' + + 'canonical_alias TEXT, ' + // The address in the form: #roomname:homeserver.org + + // Security rules + 'guest_access TEXT, ' + + 'history_visibility TEXT, ' + + 'join_rules TEXT, ' + + + // Power levels + 'power_events_default INTEGER, ' + + 'power_state_default INTEGER, ' + + 'power_redact INTEGER, ' + + 'power_invite INTEGER, ' + + 'power_ban INTEGER, ' + + 'power_kick INTEGER, ' + + 'power_user_default INTEGER, ' + + + // Power levels for events + 'power_event_avatar INTEGER, ' + + 'power_event_history_visibility INTEGER, ' + + 'power_event_canonical_alias INTEGER, ' + + 'power_event_aliases INTEGER, ' + + 'power_event_name INTEGER, ' + + 'power_event_power_levels INTEGER, ' + + 'UNIQUE(id))'; + + /// The database sheme for the Event class. + static final String EventScheme = 'CREATE TABLE IF NOT EXISTS Events(' + + 'id TEXT PRIMARY KEY, ' + + 'chat_id TEXT, ' + + 'origin_server_ts INTEGER, ' + + 'sender TEXT, ' + + 'state_key TEXT, ' + + 'content_body TEXT, ' + + 'type TEXT, ' + + 'content_json TEXT, ' + + "status INTEGER, " + + 'UNIQUE(id))'; + + /// The database sheme for the User class. + static final String MemberScheme = 'CREATE TABLE IF NOT EXISTS Memberships(' + + 'chat_id TEXT, ' + // The chat id of this membership + 'matrix_id TEXT, ' + // The matrix id of this user + 'displayname TEXT, ' + + 'avatar_url TEXT, ' + + 'membership TEXT, ' + // The status of the membership. Must be one of [join, invite, ban, leave] + 'power_level INTEGER, ' + // The power level of this user. Must be in [0,..,100] + 'UNIQUE(chat_id, matrix_id))'; +} \ No newline at end of file diff --git a/lib/src/User.dart b/lib/src/User.dart new file mode 100644 index 0000000..120abd9 --- /dev/null +++ b/lib/src/User.dart @@ -0,0 +1,33 @@ +import 'package:famedlysdk/src/Client.dart'; +import 'package:famedlysdk/src/utils/MxContent.dart'; +import 'package:famedlysdk/src/Room.dart'; + +class User { + final String status; + final String mxid; + final String displayName; + final MxContent avatar_url; + final String directChatRoomId; + final Room room; + + const User( + this.mxid, { + this.status, + this.displayName, + this.avatar_url, + this.directChatRoomId, + this.room, + }); + + String calcDisplayname() => displayName.isEmpty + ? mxid.replaceFirst("@", "").split(":")[0] + : displayName; + + static User fromJson(Map json) { + return User(json['matrix_id'], + displayName: json['displayname'], + avatar_url: MxContent(json['avatar_url']), + status: "", + directChatRoomId: ""); + } +} diff --git a/lib/src/responses/ErrorResponse.dart b/lib/src/responses/ErrorResponse.dart new file mode 100644 index 0000000..ecd571c --- /dev/null +++ b/lib/src/responses/ErrorResponse.dart @@ -0,0 +1,23 @@ +/// Represents a special response from the Homeserver for errors. +class ErrorResponse { + + /// The unique identifier for this error. + String errcode; + + /// A human readable error description. + String error; + + ErrorResponse({this.errcode, this.error}); + + ErrorResponse.fromJson(Map json) { + errcode = json['errcode']; + error = json['error'] ?? ""; + } + + Map toJson() { + final Map data = new Map(); + data['errcode'] = this.errcode; + data['error'] = this.error; + return data; + } +} diff --git a/lib/src/sync/EventUpdate.dart b/lib/src/sync/EventUpdate.dart new file mode 100644 index 0000000..4bb4d7e --- /dev/null +++ b/lib/src/sync/EventUpdate.dart @@ -0,0 +1,20 @@ +/// 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 String eventType; + + /// Most events belong to a room. If not, this equals to eventType. + final String roomID; + + /// See (Matrix Room Events)[https://matrix.org/docs/spec/client_server/r0.4.0.html#room-events] + /// and (Matrix Events)[https://matrix.org/docs/spec/client_server/r0.4.0.html#id89] for more + /// informations. + final String type; + + // The json payload of the content of this event. + final dynamic content; + + EventUpdate({this.eventType, this.roomID, this.type, this.content}); +} diff --git a/lib/src/sync/RoomUpdate.dart b/lib/src/sync/RoomUpdate.dart new file mode 100644 index 0000000..5fc27c5 --- /dev/null +++ b/lib/src/sync/RoomUpdate.dart @@ -0,0 +1,33 @@ +/// Represents a new room or an update for an +/// already known room. +class RoomUpdate { + + /// All rooms have an idea in the format: !uniqueid:server.abc + final String id; + + /// The current membership state of the user in this room. + final String membership; + + /// Represents the number of unead notifications. This probably doesn't fit the number + /// of unread messages. + final num notification_count; + + // The number of unread highlighted notifications. + final num highlight_count; + + /// If there are too much new messages, the [homeserver] will only send the + /// last X (default is 10) messages and set the [limitedTimelinbe] flag to true. + final bool limitedTimeline; + + /// Represents the current position of the client in the room history. + final String prev_batch; + + RoomUpdate({ + this.id, + this.membership, + this.notification_count, + this.highlight_count, + this.limitedTimeline, + this.prev_batch, + }); +} diff --git a/lib/src/utils/ChatTime.dart b/lib/src/utils/ChatTime.dart new file mode 100644 index 0000000..0c5a9bc --- /dev/null +++ b/lib/src/utils/ChatTime.dart @@ -0,0 +1,74 @@ +import 'package:intl/intl.dart'; + +class ChatTime { + DateTime dateTime = DateTime.now(); + + ChatTime(num ts) { + if (ts != null) + dateTime = DateTime.fromMicrosecondsSinceEpoch(ts * 1000); + } + + ChatTime.now() { + dateTime = DateTime.now(); + } + + String toString() { + DateTime now = DateTime.now(); + + bool sameYear = now.year == dateTime.year; + + bool sameDay = + sameYear && now.month == dateTime.month && now.day == dateTime.day; + + bool sameWeek = sameYear && !sameDay && now.millisecondsSinceEpoch - dateTime.millisecondsSinceEpoch < 1000*60*60*24*7; + + if (sameDay) { + return toTimeString(); + } else if (sameWeek) { + switch (dateTime.weekday) { // TODO: Needs localization + case 1: + return "Montag"; + case 2: + return "Dienstag"; + case 3: + return "Mittwoch"; + case 4: + return "Donnerstag"; + case 5: + return "Freitag"; + case 6: + return "Samstag"; + case 7: + return "Sonntag"; + } + } else if (sameYear) { + return DateFormat('dd.MM').format(dateTime); + } else { + return DateFormat('dd.MM.yyyy').format(dateTime); + } + } + + num toTimeStamp() { + return dateTime.microsecondsSinceEpoch; + } + + bool sameEnvironment(ChatTime prevTime) { + return toTimeStamp() - prevTime.toTimeStamp() < 1000*60*5; + } + + String toTimeString() { + return DateFormat('HH:mm').format(dateTime); + } + + String toEventTimeString() { + DateTime now = DateTime.now(); + + bool sameYear = now.year == dateTime.year; + + bool sameDay = + sameYear && now.month == dateTime.month && now.day == dateTime.day; + + if (sameDay) return toTimeString(); + return "${toString()}, ${DateFormat('HH:mm').format(dateTime)}"; + } +} diff --git a/lib/src/utils/MxContent.dart b/lib/src/utils/MxContent.dart new file mode 100644 index 0000000..e179a9e --- /dev/null +++ b/lib/src/utils/MxContent.dart @@ -0,0 +1,24 @@ +import 'package:famedlysdk/src/Client.dart'; +import 'dart:core'; + +class MxContent { + + final String _mxc; + + MxContent(this._mxc); + + get mxc => _mxc; + + getDownloadLink (Client matrix) => "https://${matrix.homeserver}/_matrix/media/r0/download/${_mxc.replaceFirst("mxc://","")}/"; + + getThumbnail (Client matrix, {num width, num height, ThumbnailMethod method}) { + String methodStr = "crop"; + if (method == ThumbnailMethod.scale) methodStr = "scale"; + width = width.round(); + height = height.round(); + return "${matrix.homeserver}/_matrix/media/r0/thumbnail/${_mxc.replaceFirst("mxc://","")}?width=$width&height=$height&method=$methodStr"; + } + +} + +enum ThumbnailMethod {crop, scale} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..4f6c7e0 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,287 @@ +# Generated by pub +# See https://www.dartlang.org/tools/pub/glossary#lockfile +packages: + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "0.36.3" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.2" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.14.11" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.6" + csslib: + dependency: transitive + description: + name: csslib + url: "https://pub.dartlang.org" + source: hosted + version: "0.16.0" + dart_style: + dependency: transitive + description: + name: dart_style + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.7" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + front_end: + dependency: transitive + description: + name: front_end + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.18" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.7" + html: + dependency: transitive + description: + name: html + url: "https://pub.dartlang.org" + source: hosted + version: "0.14.0+2" + http: + dependency: "direct main" + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.0+2" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.3" + intl: + dependency: transitive + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.15.8" + intl_translation: + dependency: "direct main" + description: + name: intl_translation + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.5" + kernel: + dependency: transitive + description: + name: kernel + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.18" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.5" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.6" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.2" + pedantic: + dependency: transitive + description: + name: pedantic + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.0" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.2" + quiver: + dependency: transitive + description: + name: quiver + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.5" + sqflite: + dependency: "direct main" + description: + name: sqflite + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.5" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.3" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" + synchronized: + dependency: transitive + description: + name: synchronized + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.4" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.6" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.8" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.7+10" + yaml: + dependency: transitive + description: + name: yaml + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.15" +sdks: + dart: ">=2.2.0 <3.0.0" + flutter: ">=1.2.1 <2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..d621b01 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,62 @@ +name: famedlysdk +description: Matrix SDK for the famedly talk app written in dart. +version: 0.0.1 +author: famedly +homepage: https://famedly.com + +environment: + sdk: ">=2.1.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + + # Database + sqflite: ^1.1.0 + + # Connection + http: ^0.12.0+2 + + # Time formatting + intl_translation: ^0.17.1 + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://www.dartlang.org/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/test/Client_test.dart b/test/Client_test.dart new file mode 100644 index 0000000..5e5e93d --- /dev/null +++ b/test/Client_test.dart @@ -0,0 +1,216 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:famedlysdk/src/Client.dart'; +import 'package:famedlysdk/src/Connection.dart'; +import 'package:famedlysdk/src/sync/EventUpdate.dart'; +import 'package:famedlysdk/src/sync/RoomUpdate.dart'; +import 'package:famedlysdk/src/responses/ErrorResponse.dart'; +import 'dart:async'; +import 'FakeMatrixApi.dart'; + +void main() { + Client matrix; + + Future> roomUpdateListFuture; + Future> eventUpdateListFuture; + + /// All Tests related to the Login + group("FluffyMatrix", () { + /// Check if all Elements get created + + final create = (WidgetTester tester) { + + matrix = Client("testclient"); + matrix.connection.httpClient = FakeMatrixApi(); + matrix.homeserver = "https://fakeServer.notExisting"; + + roomUpdateListFuture = matrix.connection.onRoomUpdate.stream.toList(); + eventUpdateListFuture = matrix.connection.onEvent.stream.toList(); + }; + testWidgets('should get created', create); + + test("Get version", () async { + final versionResp = + await matrix.connection.jsonRequest(type: "GET", action: "/client/versions"); + expect(versionResp is ErrorResponse, false); + expect(versionResp["versions"].indexOf("r0.4.0") != -1, true); + matrix.matrixVersions = List.from(versionResp["versions"]); + matrix.lazyLoadMembers = true; + }); + + test("Get login types", () async { + final resp = + await matrix.connection.jsonRequest(type: "GET", action: "/client/r0/login"); + expect(resp is ErrorResponse, false); + expect(resp["flows"] is List, true); + bool hasMLoginType = false; + for (int i = 0; i < resp["flows"].length; i++) + if (resp["flows"][i]["type"] is String && + resp["flows"][i]["type"] == "m.login.password") { + hasMLoginType = true; + break; + } + expect(hasMLoginType, true); + }); + + final loginText = () async{ + final resp = await matrix + .connection.jsonRequest(type: "POST", action: "/client/r0/login", data: { + "type": "m.login.password", + "user": "test", + "password": "1234", + "initial_device_display_name": "Fluffy Matrix Client" + }); + expect(resp is ErrorResponse, false); + + Future loginStateFuture = matrix.connection.onLoginStateChanged.stream.first; + Future firstSyncFuture = matrix.connection.onFirstSync.stream.first; + Future syncFuture = matrix.connection.onSync.stream.first; + + matrix.connection.connect( + newToken: resp["access_token"], + newUserID: resp["user_id"], + newHomeserver: matrix.homeserver, + newDeviceName: "Text Matrix Client", + newDeviceID: resp["device_id"], + newMatrixVersions: matrix.matrixVersions, + newLazyLoadMembers: matrix.lazyLoadMembers); + + expect(matrix.accessToken == resp["access_token"], true); + expect(matrix.deviceName == "Text Matrix Client", true); + expect(matrix.deviceID == resp["device_id"], true); + expect(matrix.userID == resp["user_id"], true); + + LoginState loginState = await loginStateFuture; + bool firstSync = await firstSyncFuture; + dynamic sync = await syncFuture; + + expect(loginState, LoginState.logged); + expect(firstSync, true); + expect(sync["next_batch"] == matrix.prevBatch, true); + }; + + test('Login', loginText); + + test('Try to get ErrorResponse', () async{ + final resp = await matrix + .connection.jsonRequest(type: "PUT", action: "/non/existing/path"); + expect(resp is ErrorResponse, true); + }); + + test('Logout', () async{ + final dynamic resp = await matrix + .connection.jsonRequest(type: "POST", action: "/client/r0/logout"); + expect(resp is ErrorResponse, false); + + Future loginStateFuture = matrix.connection.onLoginStateChanged.stream.first; + + matrix.connection.clear(); + + expect(matrix.accessToken == null, true); + expect(matrix.homeserver == null, true); + expect(matrix.userID == null, true); + expect(matrix.deviceID == null, true); + expect(matrix.deviceName == null, true); + expect(matrix.matrixVersions == null, true); + expect(matrix.lazyLoadMembers == null, true); + expect(matrix.prevBatch == null, true); + + LoginState loginState = await loginStateFuture; + expect(loginState, LoginState.loggedOut); + }); + + test('Room Update Test', () async{ + matrix.connection.onRoomUpdate.close(); + + List roomUpdateList = await roomUpdateListFuture; + + expect(roomUpdateList.length,3); + + expect(roomUpdateList[0].id=="!726s6s6q:example.com", true); + expect(roomUpdateList[0].membership=="join", true); + expect(roomUpdateList[0].prev_batch=="t34-23535_0_0", true); + expect(roomUpdateList[0].limitedTimeline==true, true); + expect(roomUpdateList[0].notification_count==2, true); + expect(roomUpdateList[0].highlight_count==2, true); + + expect(roomUpdateList[1].id=="!696r7674:example.com", true); + expect(roomUpdateList[1].membership=="invite", true); + expect(roomUpdateList[1].prev_batch=="", true); + expect(roomUpdateList[1].limitedTimeline==false, true); + expect(roomUpdateList[1].notification_count==0, true); + expect(roomUpdateList[1].highlight_count==0, true); + + expect(roomUpdateList[2].id=="!5345234234:example.com", true); + expect(roomUpdateList[2].membership=="leave", true); + expect(roomUpdateList[2].prev_batch=="", true); + expect(roomUpdateList[2].limitedTimeline==false, true); + expect(roomUpdateList[2].notification_count==0, true); + expect(roomUpdateList[2].highlight_count==0, true); + }); + + test('Event Update Test', () async{ + matrix.connection.onEvent.close(); + + List eventUpdateList = await eventUpdateListFuture; + + expect(eventUpdateList.length,10); + + expect(eventUpdateList[0].eventType=="m.room.member", true); + expect(eventUpdateList[0].roomID=="!726s6s6q:example.com", true); + expect(eventUpdateList[0].type=="state", true); + + expect(eventUpdateList[1].eventType=="m.room.member", true); + expect(eventUpdateList[1].roomID=="!726s6s6q:example.com", true); + expect(eventUpdateList[1].type=="timeline", true); + + expect(eventUpdateList[2].eventType=="m.room.message", true); + expect(eventUpdateList[2].roomID=="!726s6s6q:example.com", true); + expect(eventUpdateList[2].type=="timeline", true); + + expect(eventUpdateList[3].eventType=="m.tag", true); + expect(eventUpdateList[3].roomID=="!726s6s6q:example.com", true); + expect(eventUpdateList[3].type=="account_data", true); + + expect(eventUpdateList[4].eventType=="org.example.custom.room.config", true); + expect(eventUpdateList[4].roomID=="!726s6s6q:example.com", true); + expect(eventUpdateList[4].type=="account_data", true); + + expect(eventUpdateList[5].eventType=="m.room.name", true); + expect(eventUpdateList[5].roomID=="!696r7674:example.com", true); + expect(eventUpdateList[5].type=="invite_state", true); + + expect(eventUpdateList[6].eventType=="m.room.member", true); + expect(eventUpdateList[6].roomID=="!696r7674:example.com", true); + expect(eventUpdateList[6].type=="invite_state", true); + + expect(eventUpdateList[7].eventType=="m.presence", true); + expect(eventUpdateList[7].roomID=="presence", true); + expect(eventUpdateList[7].type=="presence", true); + + expect(eventUpdateList[8].eventType=="org.example.custom.config", true); + expect(eventUpdateList[8].roomID=="account_data", true); + expect(eventUpdateList[8].type=="account_data", true); + + expect(eventUpdateList[9].eventType=="m.new_device", true); + expect(eventUpdateList[9].roomID=="to_device", true); + expect(eventUpdateList[9].type=="to_device", true); + + + }); + + testWidgets('should get created', create); + + test('Login', loginText); + + test('Logout when token is unknown', () async{ + Future loginStateFuture = matrix.connection.onLoginStateChanged.stream.first; + final resp = await matrix + .connection.jsonRequest(type: "DELETE", action: "/unknown/token"); + + LoginState state = await loginStateFuture; + expect(state, LoginState.loggedOut); + expect(matrix.isLogged(), false); + }); + + }); +} diff --git a/test/FakeMatrixApi.dart b/test/FakeMatrixApi.dart new file mode 100644 index 0000000..7c65576 --- /dev/null +++ b/test/FakeMatrixApi.dart @@ -0,0 +1,192 @@ +import 'package:http/testing.dart'; +import 'dart:convert'; +import 'dart:core'; +import 'dart:math'; +import 'package:http/http.dart'; + +class FakeMatrixApi extends MockClient { + FakeMatrixApi() + : super((request) async { + // Collect data from Request + final String action = request.url.path.split("/_matrix")[1]; + final String method = request.method; + final dynamic data = + method == "GET" ? request.url.queryParameters : request.body; + var res = {}; + + //print("$method request to $action with Data: $data"); + + // Sync requests with timeout + if (data is Map && data["timeout"] is String) { + await new Future.delayed(Duration(seconds: 5)); + } + + // Call API + if (api.containsKey(method) && api[method].containsKey(action)) + res = api[method][action](data); + else + res = { + "errcode": "M_UNRECOGNIZED", + "error": "Unrecognized request" + }; + + return Response(json.encode(res), 100); + }); + + static final Map> api = { + "GET": { + "/client/versions": (var req) => { + "versions": ["r0.0.1", "r0.1.0", "r0.2.0", "r0.3.0", "r0.4.0"], + "unstable_features": {"m.lazy_load_members": true}, + }, + "/client/r0/login": (var req) => { + "flows": [ + {"type": "m.login.password"} + ] + }, + "/client/r0/sync": (var req) => { + "next_batch": Random().nextDouble().toString(), + "presence": { + "events": [ + { + "sender": "@alice:example.com", + "type": "m.presence", + "content": {"presence": "online"} + } + ] + }, + "account_data": { + "events": [ + { + "type": "org.example.custom.config", + "content": {"custom_config_key": "custom_config_value"} + } + ] + }, + "to_device": { + "events": [ + { + "sender": "@alice:example.com", + "type": "m.new_device", + "content": { + "device_id": "XYZABCDE", + "rooms": ["!726s6s6q:example.com"] + } + } + ] + }, + "rooms": { + "join": { + "!726s6s6q:example.com": { + "unread_notifications": { + "highlight_count": 2, + "notification_count": 2, + }, + "state": { + "events": [ + { + "sender": "@alice:example.com", + "type": "m.room.member", + "state_key": "@alice:example.com", + "content": {"membership": "join"}, + "origin_server_ts": 1417731086795, + "event_id": "66697273743031:example.com" + } + ] + }, + "timeline": { + "events": [ + { + "sender": "@bob:example.com", + "type": "m.room.member", + "state_key": "@bob:example.com", + "content": {"membership": "join"}, + "prev_content": {"membership": "invite"}, + "origin_server_ts": 1417731086795, + "event_id": "7365636s6r6432:example.com" + }, + { + "sender": "@alice:example.com", + "type": "m.room.message", + "txn_id": "1234", + "content": {"body": "I am a fish", "msgtype": "m.text"}, + "origin_server_ts": 1417731086797, + "event_id": "74686972643033:example.com" + } + ], + "limited": true, + "prev_batch": "t34-23535_0_0" + }, + "ephemeral": { + "events": [ + { + "type": "m.typing", + "content": { + "user_ids": ["@alice:example.com"] + } + } + ] + }, + "account_data": { + "events": [ + { + "type": "m.tag", + "content": { + "tags": { + "work": {"order": 1} + } + } + }, + { + "type": "org.example.custom.room.config", + "content": {"custom_config_key": "custom_config_value"} + } + ] + } + } + }, + "invite": { + "!696r7674:example.com": { + "invite_state": { + "events": [ + { + "sender": "@alice:example.com", + "type": "m.room.name", + "state_key": "", + "content": {"name": "My Room Name"} + }, + { + "sender": "@alice:example.com", + "type": "m.room.member", + "state_key": "@bob:example.com", + "content": {"membership": "invite"} + } + ] + } + } + }, + "leave": { + "!5345234234:example.com": { + "timeline": {"events": []} + }, + }, + } + }, + }, + "POST": { + "/client/r0/login": (var req) => { + "user_id": "@test:fakeServer.notExisting", + "access_token": "abc123", + "device_id": "GHTYAJCE" + }, + "/client/r0/logout": (var reqI) => {}, + "/client/r0/logout/all": (var reqI) => {}, + }, + "PUT": {}, + "DELETE": { + "/unknown/token": (var req) => { + "errcode": "M_UNKNOWN_TOKEN" + }, + }, + }; +}