Initial commit

This commit is contained in:
Christian Pauly 2019-06-09 12:16:48 +02:00
commit 77be6102f6
22 changed files with 2591 additions and 0 deletions

70
.gitignore vendored Normal file
View File

@ -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

18
.gitlab-ci.yml Normal file
View File

@ -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

10
.metadata Normal file
View File

@ -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

84
CHANGELOG.md Normal file
View File

@ -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"
}
);
```

1
LICENSE Normal file
View File

@ -0,0 +1 @@
TODO: Add your license here.

14
README.md Normal file
View File

@ -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.

13
lib/famedlysdk.dart Normal file
View File

@ -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';

169
lib/src/Client.dart Normal file
View File

@ -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<String> 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<bool> 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<String> versions = List<String>.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<dynamic> 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<bool> 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<void> logout() async {
final dynamic resp =
await connection.jsonRequest(type: "POST", action: "/client/r0/logout/all");
if (resp == null) return;
await connection.clear();
}
}

415
lib/src/Connection.dart Normal file
View File

@ -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<EventUpdate> onEvent =
new StreamController.broadcast();
/// Outside of the events there are updates for the global chat states which
/// are handled by this signal:
final StreamController<RoomUpdate> onRoomUpdate =
new StreamController.broadcast();
/// Called when the login state e.g. user gets logged out.
final StreamController<LoginState> onLoginStateChanged =
new StreamController.broadcast();
/// Synchronization erros are coming here.
final StreamController<ErrorResponse> onError =
new StreamController.broadcast();
/// This is called once, when the first sync has received.
final StreamController<bool> onFirstSync = new StreamController.broadcast();
/// When a new sync response is coming in, this gives the complete payload.
final StreamController<dynamic> 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<String> 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<dynamic> 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<String, String> 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<String, dynamic> jsonResp;
try {
jsonResp = jsonDecode(resp.body) as Map<String, dynamic>;
} 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<dynamic> _syncRequest;
Future<void> _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<String, dynamic>) {
if (sync["rooms"]["join"] is Map<String, dynamic>)
_handleRooms(sync["rooms"]["join"], "join");
if (sync["rooms"]["invite"] is Map<String, dynamic>)
_handleRooms(sync["rooms"]["invite"], "invite");
if (sync["rooms"]["leave"] is Map<String, dynamic>)
_handleRooms(sync["rooms"]["leave"], "leave");
}
if (sync["presence"] is Map<String, dynamic> &&
sync["presence"]["events"] is List<dynamic>) {
_handleGlobalEvents(sync["presence"]["events"], "presence");
}
if (sync["account_data"] is Map<String, dynamic> &&
sync["account_data"]["events"] is List<dynamic>) {
_handleGlobalEvents(sync["account_data"]["events"], "account_data");
}
if (sync["to_device"] is Map<String, dynamic> &&
sync["to_device"]["events"] is List<dynamic>) {
_handleGlobalEvents(sync["to_device"]["events"], "to_device");
}
onSync.add(sync);
}
void _handleRooms(Map<String, dynamic> 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<String, dynamic>) {
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<String, dynamic>) {
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<String, dynamic> &&
room["state"]["events"] is List<dynamic>)
_handleRoomEvents(id, room["state"]["events"], "state");
if (room["invite_state"] is Map<String, dynamic> &&
room["invite_state"]["events"] is List<dynamic>)
_handleRoomEvents(
id, room["invite_state"]["events"], "invite_state");
if (room["timeline"] is Map<String, dynamic> &&
room["timeline"]["events"] is List<dynamic>)
_handleRoomEvents(id, room["timeline"]["events"], "timeline");
if (room["ephemetal"] is Map<String, dynamic> &&
room["ephemetal"]["events"] is List<dynamic>)
_handleEphemerals(id, room["ephemetal"]["events"]);
if (room["account_data"] is Map<String, dynamic> &&
room["account_data"]["events"] is List<dynamic>)
_handleRoomEvents(
id, room["account_data"]["events"], "account_data");
});
}
void _handleEphemerals(String id, List<dynamic> events) {
for (num i = 0; i < events.length; i++) {
if (!(events[i]["type"] is String &&
events[i]["content"] is Map<String, dynamic>)) continue;
if (events[i]["type"] == "m.receipt") {
events[i]["content"].forEach((String e, dynamic value) {
if (!(events[i]["content"][e] is Map<String, dynamic> &&
events[i]["content"][e]["m.read"] is Map<String, dynamic>))
return;
events[i]["content"][e]["m.read"]
.forEach((String user, dynamic value) async {
if (!(events[i]["content"][e]["m.read"]["user"]
is Map<String, dynamic> &&
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<String>)) continue;
List<String> 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<dynamic> events, String type) {
for (num i = 0; i < events.length; i++) {
_handleEvent(events[i], chat_id, type);
}
}
void _handleGlobalEvents(List<dynamic> events, String type) {
for (int i = 0; i < events.length; i++)
_handleEvent(events[i], type, type);
}
void _handleEvent(
Map<String, dynamic> 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<Null> 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<void> Function();
enum LoginState { logged, loggedOut }

120
lib/src/Event.dart Normal file
View File

@ -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<String,dynamic> 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<String, dynamic> jsonObj) {
Map<String,dynamic> 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<List<Event>> getEventList(Client matrix, String roomID) async{
List<Map<String, dynamic>> 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<Event> 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<String,int> StatusTypes = {
"ERROR": -1,
"SENDING": 0,
"SENT": 1,
"RECEIVED": 2,
};

197
lib/src/Room.dart Normal file
View File

@ -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<Event> 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<dynamic> 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<dynamic> 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<List<Event>> get eventsStream {
return Stream<List<Event>>.fromIterable(Iterable<List<Event>>.generate(
this.events.length, (int index) => this.events)).asBroadcastStream();
}
Future<void> 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<dynamic> 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<dynamic> 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<dynamic> 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<dynamic> 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<dynamic> 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<dynamic> 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<Room> getRoomFromTableRow(
Map<String, dynamic> 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<Room> getRoomById(String id, Client matrix) async {
List<Map<String, dynamic>> 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<Room> loadRoomEvents(String id, Client matrix) async {
Room room = await Room.getRoomById(id, matrix);
room.events = await Event.getEventList(matrix, id);
return room;
}
Future<List<User>> requestParticipants(Client matrix) async {
List<User> participants = [];
dynamic res = await matrix.connection.jsonRequest(
type: "GET", action: "/client/r0/rooms/${roomID}/members");
if (res is ErrorResponse || !(res["chunk"] is List<dynamic>))
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;
}
}

516
lib/src/Store.dart Normal file
View File

@ -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<Map> 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<String> queryPrevBatch() async{
List<Map> 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<void> 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<void> 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<void> transaction(Future<void> 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<void> 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<void> 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<void> 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<String, dynamic>)
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<String> 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<String, dynamic>) {
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<String, dynamic>) {
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<User> getUser(
{String matrixID, String roomID}) async {
List<Map<String, dynamic>> 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<List<User>> loadContacts() async {
List<Map<String, dynamic>> res = await db.rawQuery(
"SELECT * FROM Memberships WHERE matrix_id!=? GROUP BY matrix_id ORDER BY displayname",
[client.userID]);
List<User> 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<List<User>> loadParticipants(String roomID) async {
List<Map<String, dynamic>> res = await db.rawQuery(
"SELECT * " +
" FROM Memberships " +
" WHERE chat_id=? " +
" AND membership='join'",
[roomID]);
List<User> 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<List<Room>> getRoomList() async {
List<Map<String, dynamic>> 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<Room> 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<String> getAvatarFromSingleChat(
String roomID) async {
String avatarStr = "";
List<Map<String, dynamic>> 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<String> getChatNameFromMemberNames(
String roomID) async {
String displayname = 'Empty chat';
List<Map<String, dynamic>> 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))';
}

33
lib/src/User.dart Normal file
View File

@ -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<String, dynamic> json) {
return User(json['matrix_id'],
displayName: json['displayname'],
avatar_url: MxContent(json['avatar_url']),
status: "",
directChatRoomId: "");
}
}

View File

@ -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<String, dynamic> json) {
errcode = json['errcode'];
error = json['error'] ?? "";
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['errcode'] = this.errcode;
data['error'] = this.error;
return data;
}
}

View File

@ -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});
}

View File

@ -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,
});
}

View File

@ -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)}";
}
}

View File

@ -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}

287
pubspec.lock Normal file
View File

@ -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"

62
pubspec.yaml Normal file
View File

@ -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

216
test/Client_test.dart Normal file
View File

@ -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<List<RoomUpdate>> roomUpdateListFuture;
Future<List<EventUpdate>> 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<String>.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<dynamic>, 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<LoginState> loginStateFuture = matrix.connection.onLoginStateChanged.stream.first;
Future<bool> firstSyncFuture = matrix.connection.onFirstSync.stream.first;
Future<dynamic> 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<LoginState> 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<RoomUpdate> 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<EventUpdate> 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<LoginState> 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);
});
});
}

192
test/FakeMatrixApi.dart Normal file
View File

@ -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<String, dynamic> && 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<String, Map<String, dynamic>> 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"
},
},
};
}