/* * Copyright (c) 2019 Zender & Kurtz GbR. * * Authors: * Christian Pauly * Marcel Radzio * * This file is part of famedlysdk. * * famedlysdk is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * famedlysdk is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with Foobar. If not, see . */ import 'dart:async'; import 'dart:convert'; import 'dart:core'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart' as p; import 'sync/EventUpdate.dart'; import 'sync/RoomUpdate.dart'; import 'Client.dart'; import 'User.dart'; import 'Room.dart'; import 'Connection.dart'; /// Responsible to store all data persistent and to query objects from the /// database. 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(ClientsScheme); await db.execute(RoomsScheme); await db.execute(ParticipantsScheme); await db.execute(EventsScheme); }, onUpgrade: (Database db, int oldVersion, int newVersion) async{ if (oldVersion != newVersion) { await db.rawDelete("DELETE FROM Rooms"); await db.rawDelete("DELETE FROM Participants"); await db.rawDelete("DELETE FROM Events"); db.rawUpdate("UPDATE Clients SET prev_batch='' WHERE client=?", [client.clientName]); } }); 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"].toString().isEmpty ? null : 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 Rooms"); await _db.rawDelete("DELETE FROM Participants"); 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 Rooms " + "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 Rooms 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 Rooms 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 Rooms 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 Rooms 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 Rooms 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 Rooms 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 Rooms 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 Rooms 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 Rooms 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 Rooms 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 Participants VALUES(?,?,?,?,?,0)", [ chat_id, state_key, insertDisplayname, insertAvatarUrl, membership ]); String queryStr = "UPDATE Participants 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 Rooms 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 Rooms 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 Participants SET power_level=? WHERE matrix_id=? AND chat_id=?", [power_level, user, chat_id]); txn.rawInsert( "INSERT OR IGNORE INTO Participants 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 Participants 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 Participants 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 Participants " + " 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 Rooms 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 Participants " + " WHERE Participants.chat_id=? " + " AND (Participants.membership='join' OR Participants.membership='invite') " + " AND Participants.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 Participants.displayname, Participants.matrix_id, Participants.membership FROM Participants " + " WHERE Participants.chat_id=? " + " AND (Participants.membership='join' OR Participants.membership='invite') " + " AND Participants.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 ClientsScheme = '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 RoomsScheme = 'CREATE TABLE IF NOT EXISTS Rooms(' + '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 'direct_chat_matrix_id TEXT, ' + //If this room is a direct chat, this is the matrix ID of the user 'notification_settings TEXT, ' + // Must be one of [all, mention] // 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 EventsScheme = '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 ParticipantsScheme = 'CREATE TABLE IF NOT EXISTS Participants(' + '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))'; }