import 'dart:async'; import 'dart:convert'; import 'dart:developer' as developer; import 'dart:io'; import 'package:path_provider/path_provider.dart'; import 'package:tetra_stats/data_objects/tetrio_multiplayer_replay.dart'; import 'package:tetra_stats/main.dart' show packageInfo; import 'package:flutter/foundation.dart'; import 'package:tetra_stats/services/custom_http_client.dart'; import 'package:http/http.dart' as http; import 'package:tetra_stats/services/crud_exceptions.dart'; import 'package:tetra_stats/services/sqlite_db_controller.dart'; import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:csv/csv.dart'; const String dbName = "TetraStats.db"; const String tetrioUsersTable = "tetrioUsers"; const String tetrioUsersToTrackTable = "tetrioUsersToTrack"; const String tetraLeagueMatchesTable = "tetrioAlphaLeagueMathces"; const String tetrioTLReplayStatsTable = "tetrioTLReplayStats"; const String idCol = "id"; const String replayID = "replayId"; const String nickCol = "nickname"; const String timestamp = "timestamp"; const String endContext1 = "endContext1"; const String endContext2 = "endContext2"; const String statesCol = "jsonStates"; const String player1id = "player1id"; const String player2id = "player2id"; /// Table, that store players data, their stats at some moments of time const String createTetrioUsersTable = ''' CREATE TABLE IF NOT EXISTS "tetrioUsers" ( "id" TEXT UNIQUE, "nickname" TEXT, "jsonStates" TEXT, PRIMARY KEY("id") );'''; /// Table, that store ids of players we need keep track of const String createTetrioUsersToTrack = ''' CREATE TABLE IF NOT EXISTS "tetrioUsersToTrack" ( "id" TEXT NOT NULL UNIQUE, PRIMARY KEY("ID") ) '''; /// Table of Tetra League matches. Each match corresponds with their own players and end contexts const String createTetrioTLRecordsTable = ''' CREATE TABLE IF NOT EXISTS "tetrioAlphaLeagueMathces" ( "id" TEXT NOT NULL UNIQUE, "replayId" TEXT, "player1id" TEXT, "player2id" TEXT, "timestamp" TEXT, "endContext1" TEXT, "endContext2" TEXT, PRIMARY KEY("id") ) '''; /// Table, that contains results of replay analysis in order to not analyze it more, than one time. const String createTetrioTLReplayStats = ''' CREATE TABLE IF NOT EXISTS "tetrioTLReplayStats" ( "id" TEXT NOT NULL, "data" TEXT NOT NULL, "freyhoe" TEXT, PRIMARY KEY("id") ) '''; class TetrioService extends DB { final Map _players = {}; // I'm trying to send as less requests, as possible, so i'm caching the results of those requests. // Usually those maps looks like this: {"cached_until_unix_milliseconds": Object} final Map _playersCache = {}; final Map> _recordsCache = {}; final Map _replaysCache = {}; // the only one is different: {"replayID": [replayString, replayBytes]} final Map _leaderboardsCache = {}; final Map _lbPositions = {}; final Map> _newsCache = {}; final Map> _topTRcache = {}; final Map>> _cutoffsCache = {}; final Map _tlStreamsCache = {}; /// Thing, that sends every request to the API endpoints final client = kDebugMode ? UserAgentClient("Kagari-chan loves osk (Tetra Stats dev build)", http.Client()) : UserAgentClient("Tetra Stats v${packageInfo.version} (dm @dan63047 if someone abuse that software)", http.Client()); /// We should have only one instanse of this service static final TetrioService _shared = TetrioService._sharedInstance(); factory TetrioService() => _shared; late final StreamController> _tetrioStreamController; TetrioService._sharedInstance() { _tetrioStreamController = StreamController>.broadcast(onListen: () { _tetrioStreamController.sink.add(_players); }); } @override Future open() async { await super.open(); await _loadPlayers(); } Stream> get allPlayers => _tetrioStreamController.stream; /// Loading and sending to the stream everyone. Future _loadPlayers() async { final allPlayers = await getAllPlayerToTrack(); for (var element in allPlayers) { _players[element] = await getNicknameByID(element); } developer.log("_loadPlayers: $_players", name: "services/tetrio_crud"); _tetrioStreamController.add(_players); } /// Removes player entry from tetrioUsersTable with given [id]. /// Can throw an error is player with this id is not exist Future deletePlayer(String id) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final deletedPlayer = await db.delete(tetrioUsersTable, where: '$idCol = ?', whereArgs: [id.toLowerCase()]); if (deletedPlayer != 1) { throw CouldNotDeletePlayer(); } else { _players.removeWhere((key, value) => key == id); _tetrioStreamController.add(_players); } } /// Gets nickname from database or requests it from API if missing. /// Throws an exception if user not exist or request failed. Future getNicknameByID(String id) async { if (id.length <= 16) return id; // nicknames can be up to 16 symbols in length, that's how i'm differentiate nickname from ids try{ await ensureDbIsOpen(); final db = getDatabaseOrThrow(); var request = await db.query(tetrioUsersTable, limit: 1, where: '$idCol = ?', whereArgs: [id.toLowerCase()]); return request.first[nickCol] as String; } catch (e){ return await fetchPlayer(id).then((value) => value.username); } } /// Puts results of replay analysis into a tetrioTLReplayStatsTable Future saveReplayStats(ReplayData replay) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); db.insert(tetrioTLReplayStatsTable, {idCol: replay.id, "data": jsonEncode(replay.toJson())}); } void cacheLeaderboardPositions(String userID, PlayerLeaderboardPosition positions){ _lbPositions[userID] = positions; } PlayerLeaderboardPosition? getCachedLeaderboardPositions(String userID){ return _lbPositions[userID]; } /// Downloads replay from inoue (szy API). Requiers [replayID]. If request have /// different from 200 statusCode, it will throw an excepction. Returns list, that contains same replay /// as string and as binary. Future> szyGetReplay(String replayID) async { try{ // read from cache var cached = _replaysCache.entries.firstWhere((element) => element.key == replayID); return cached.value; }catch (e){ // actually going to obtain } Uri url; if (kIsWeb) { // Web version sends every request through my php script at the same domain, where Tetra Stats located because of CORS url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "tetrioReplay", "replayid": replayID}); } else { // Actually going to hit inoue url = Uri.https('inoue.szy.lol', '/api/replay/$replayID'); } // Trying to obtain replay from download directory first if (!kIsWeb){ // can't obtain download directory on web var downloadPath = await getDownloadsDirectory(); downloadPath ??= Platform.isAndroid ? Directory("/storage/emulated/0/Download") : await getApplicationDocumentsDirectory(); var replayFile = File("${downloadPath.path}/$replayID.ttrm"); if (replayFile.existsSync()) return [replayFile.readAsStringSync(), replayFile.readAsBytesSync()]; } try{ final response = await client.get(url); switch (response.statusCode) { case 200: developer.log("szyDownload: Replay downloaded", name: "services/tetrio_crud", error: response.statusCode); _replaysCache[replayID] = [response.body, response.bodyBytes]; // Puts results into the cache return [response.body, response.bodyBytes]; // if not 200 - throw a unique for each code exception case 404: throw SzyNotFound(); case 403: throw SzyForbidden(); case 429: throw SzyTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw SzyInternalProblem(); default: developer.log("szyDownload: Failed to download a replay", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { // If local http client fails developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); // just assuming, that our end user don't have acess to the internet } } /// Saves replay with given [replayID] to Download or Documents directory as [replayID].ttrm. Throws an exception, /// if file with name [replayID].ttrm exist, if it fails to get replay or unable to save replay Future saveReplay(String replayID) async { var downloadPath = await getDownloadsDirectory(); downloadPath ??= Platform.isAndroid ? Directory("/storage/emulated/0/Download") : await getApplicationDocumentsDirectory(); var replayFile = File("${downloadPath.path}/$replayID.ttrm"); if (replayFile.existsSync()) throw TetrioReplayAlreadyExist(); var replay = await szyGetReplay(replayID); await replayFile.writeAsBytes(replay[1]); return replayFile.path; } /// Gets replay with given [replayID] and returns some stats about it. If [isAvailable] is false /// or unable to get replay, it will throw an exception Future analyzeReplay(String replayID, bool isAvailable) async{ // trying retirieve existing stats from DB first await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final results = await db.query(tetrioTLReplayStatsTable, where: '$idCol = ?', whereArgs: [replayID]); if (results.isNotEmpty) return ReplayData.fromJson(jsonDecode(results.first["data"].toString())); // if success if (!isAvailable) throw ReplayNotAvalable(); // if replay too old // otherwise, actually going to download a replay and analyze it String replay = (await szyGetReplay(replayID))[0]; Map toAnalyze = jsonDecode(replay); ReplayData data = ReplayData.fromJson(toAnalyze); saveReplayStats(data); // saving to DB for later return data; } /// Gets and returns Top TR for a player with given [id]. May return null if player top tr is unknown /// or api is unavaliable (404). May throw an exception, if something else happens. Future fetchTopTR(String id) async { try{ // read from cache var cached = _topTRcache.entries.firstWhere((element) => element.value.keys.first == id); if (DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true).isAfter(DateTime.now())){ // if not expired developer.log("fetchTopTR: Top TR retrieved from cache, that expires ${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)}", name: "services/tetrio_crud"); return cached.value.values.first; }else{ // if cache expired _topTRcache.remove(cached.key); developer.log("fetchTopTR: Top TR expired (${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)})", name: "services/tetrio_crud"); } }catch(e){ // actually going to obtain developer.log("fetchTopTR: Trying to retrieve Top TR", name: "services/tetrio_crud"); } Uri url; if (kIsWeb) { // Web version sends every request through my php script at the same domain, where Tetra Stats located because of CORS url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "PeakTR", "user": id}); } else { // Actually going to hit p1nkl0bst3r api url = Uri.https('api.p1nkl0bst3r.xyz', 'toptr/$id'); } try{ final response = await client.get(url); switch (response.statusCode) { case 200: // ok - return the value _topTRcache[(DateTime.now().millisecondsSinceEpoch + 300000).toString()] = {id: double.tryParse(response.body)}; return double.tryParse(response.body); case 404: // not found - return null developer.log("fetchTopTR: Probably, player doesn't have top TR", name: "services/tetrio_crud", error: response.statusCode); _topTRcache[(DateTime.now().millisecondsSinceEpoch + 300000).toString()] = {id: null}; return null; // if not 200 or 404 - throw a unique for each code exception case 403: throw P1nkl0bst3rForbidden(); case 429: throw P1nkl0bst3rTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw P1nkl0bst3rInternalProblem(); default: developer.log("fetchTopTR: Failed to fetch top TR", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { // If local http client fails developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); // just assuming, that our end user don't have acess to the internet } } // Sidenote: as you can see, fetch functions looks and works pretty much same way, as described above, // so i'm going to document only unique differences between them Future>> fetchCutoffs() async { try{ var cached = _cutoffsCache.entries.first; if (DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true).isAfter(DateTime.now())){ // if not expired developer.log("fetchCutoffs: Cutoffs retrieved from cache, that expires ${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)}", name: "services/tetrio_crud"); return cached.value; }else{ // if cache expired _topTRcache.remove(cached.key); developer.log("fetchCutoffs: Cutoffs expired (${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)})", name: "services/tetrio_crud"); } }catch(e){ // actually going to obtain developer.log("fetchCutoffs: Trying to retrieve Cutoffs", name: "services/tetrio_crud"); } Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "PeakTR"}); } else { url = Uri.https('api.p1nkl0bst3r.xyz', 'rankcutoff', {"users": null}); } try{ final response = await client.get(url); switch (response.statusCode) { case 200: Map rawData = jsonDecode(response.body); Map data = rawData["cutoffs"] as Map; Map trCutoffs = {}; Map glickoCutoffs = {}; for (String rank in data.keys){ trCutoffs[rank] = data[rank]["rating"]; glickoCutoffs[rank] = data[rank]["glicko"]; } _cutoffsCache[(rawData["ts"] + 300000).toString()] = [trCutoffs, glickoCutoffs]; return [trCutoffs, glickoCutoffs]; case 404: developer.log("fetchCutoffs: Cutoffs are gone", name: "services/tetrio_crud", error: response.statusCode); return []; // if not 200 or 404 - throw a unique for each code exception case 403: throw P1nkl0bst3rForbidden(); case 429: throw P1nkl0bst3rTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw P1nkl0bst3rInternalProblem(); default: developer.log("fetchCutoffs: Failed to fetch top Cutoffs", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { // If local http client fails developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); // just assuming, that our end user don't have acess to the internet } } /// Retrieves Tetra League history from p1nkl0bst3r api for a player with given [id]. Returns a list of states /// (state = instance of [TetrioPlayer] at some point of time). Can throw an exception if fails to retrieve data. Future> fetchAndsaveTLHistory(String id) async { Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "TLHistory", "user": id}); } else { url = Uri.https('api.p1nkl0bst3r.xyz', 'tlhist/$id'); } try{ final response = await client.get(url); switch (response.statusCode) { case 200: // that one api returns csv instead of json List> csv = const CsvToListConverter().convert(response.body)..removeAt(0); List history = []; // doesn't return nickname, need to retrieve it separately String nick = await getNicknameByID(id); for (List entry in csv){ // each entry is one state TetrioPlayer state = TetrioPlayer( userId: id, username: nick, role: "p1nkl0bst3r", state: DateTime.parse(entry[9]), badges: [], friendCount: -1, gamesPlayed: -1, gamesWon: -1, gameTime: const Duration(seconds: -1), xp: -1, supporterTier: 0, verified: false, connections: null, tlSeason1: TetraLeagueAlpha( timestamp: DateTime.parse(entry[9]), apm: entry[6] != '' ? entry[6] : null, pps: entry[7] != '' ? entry[7] : null, vs: entry[8] != '' ? entry[8] : null, glicko: entry[4], rd: noTrRd, gamesPlayed: entry[1], gamesWon: entry[2], bestRank: "z", decaying: false, rating: entry[3], rank: entry[5], percentileRank: entry[5], percentile: rankCutoffs[entry[5]]!, standing: -1, standingLocal: -1, nextAt: -1, prevAt: -1 ), sprint: [], blitz: [] ); history.add(state); } // trying to dump it to local DB await ensureDbIsOpen(); final db = getDatabaseOrThrow(); List states = await getPlayer(id); if (states.isEmpty) await createPlayer(history.first); states.insertAll(0, history.reversed); final Map statesJson = {}; for (var e in states) { // making one big json out of this list statesJson.addEntries({(e.state.millisecondsSinceEpoch ~/ 1000).toString(): e.toJson()}.entries); } // and putting it to local DB await db.update(tetrioUsersTable, {idCol: id, nickCol: nick, statesCol: jsonEncode(statesJson)}, where: '$idCol = ?', whereArgs: [id]); return history; case 404: developer.log("fetchTLHistory: Probably, history doesn't exist", name: "services/tetrio_crud", error: response.statusCode); throw TetrioHistoryNotExist(); case 403: throw P1nkl0bst3rForbidden(); case 429: throw P1nkl0bst3rTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw P1nkl0bst3rInternalProblem(); default: developer.log("fetchTLHistory: Failed to fetch history", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } /// Docs later Future> fetchAndSaveOldTLmatches(String userID) async { Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "TLMatches", "user": userID}); } else { url = Uri.https('api.p1nkl0bst3r.xyz', 'tlmatches/$userID'); } try{ final response = await client.get(url); switch (response.statusCode) { case 200: // that one api returns csv instead of json List> csv = const CsvToListConverter().convert(response.body)..removeAt(0); List matches = []; // parsing data into TetraLeagueAlphaRecord objects for (var entry in csv){ TetraLeagueAlphaRecord match = TetraLeagueAlphaRecord( replayId: entry[0].toString(), ownId: entry[0].toString(), // i gonna disting p1nkl0bst3r entries with it timestamp: DateTime.parse(entry[1]), endContext: [ EndContextMulti( userId: entry[2].toString(), username: entry[3].toString(), naturalOrder: 0, inputs: -1, piecesPlaced: -1, handling: Handling(arr: -1, das: -1, sdf: -1, dcd: 0, cancel: true, safeLock: true), points: entry[4], wins: entry[4], secondary: entry[6], secondaryTracking: [], tertiary: entry[5], tertiaryTracking: [], extra: entry[7], extraTracking: [], success: true ), EndContextMulti( userId: entry[8].toString(), username: entry[9].toString(), naturalOrder: 1, inputs: -1, piecesPlaced: -1, handling: Handling(arr: -1, das: -1, sdf: -1, dcd: 0, cancel: true, safeLock: true), points: entry[10], wins: entry[10], secondary: entry[12], secondaryTracking: [], tertiary: entry[11], tertiaryTracking: [], extra: entry[13], extraTracking: [], success: false ) ], replayAvalable: false ); matches.add(match); } // trying to dump it to local DB TetraLeagueAlphaStream fakeStream = TetraLeagueAlphaStream(userId: userID, records: matches); saveTLMatchesFromStream(fakeStream); return matches; case 404: developer.log("fetchAndSaveOldTLmatches: Probably, history doesn't exist", name: "services/tetrio_crud", error: response.statusCode); throw TetrioHistoryNotExist(); case 403: throw P1nkl0bst3rForbidden(); case 429: throw P1nkl0bst3rTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw P1nkl0bst3rInternalProblem(); default: developer.log("fetchAndSaveOldTLmatches: Failed to fetch history", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } /// Retrieves full Tetra League leaderboard from Tetra Channel api. Returns a leaderboard object. Throws an exception if fails to retrieve. Future fetchTLLeaderboard() async { try{ var cached = _leaderboardsCache.entries.firstWhere((element) => element.value.type == "league"); if (DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true).isAfter(DateTime.now())){ developer.log("fetchTLLeaderboard: Leaderboard retrieved from cache, that expires ${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)}", name: "services/tetrio_crud"); return cached.value; }else{ _leaderboardsCache.remove(cached.key); developer.log("fetchTLLeaderboard: Leaderboard expired (${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)})", name: "services/tetrio_crud"); } }catch(e){ developer.log("fetchTLLeaderboard: Trying to retrieve leaderboard", name: "services/tetrio_crud"); } Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "TLLeaderboard"}); } else { url = Uri.https('ch.tetr.io', 'api/users/lists/league/all'); } try{ final response = await client.get(url); switch (response.statusCode) { case 200: _lbPositions.clear(); var rawJson = jsonDecode(response.body); if (rawJson['success']) { // if api confirmed that everything ok TetrioPlayersLeaderboard leaderboard = TetrioPlayersLeaderboard.fromJson(rawJson['data']['users'], "league", DateTime.fromMillisecondsSinceEpoch(rawJson['cache']['cached_at'])); developer.log("fetchTLLeaderboard: Leaderboard retrieved and cached", name: "services/tetrio_crud"); _leaderboardsCache[rawJson['cache']['cached_until'].toString()] = leaderboard; return leaderboard; } else { // idk how to hit that one developer.log("fetchTLLeaderboard: Bruh", name: "services/tetrio_crud", error: rawJson); throw Exception("Failed to get leaderboard (problems on the tetr.io side)"); // will it be on tetr.io side? } case 403: throw TetrioForbidden(); case 429: throw TetrioTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw TetrioInternalProblem(); default: developer.log("fetchTLLeaderboard: Failed to fetch leaderboard", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } TetrioPlayersLeaderboard? getCachedLeaderboard(){ return _leaderboardsCache.entries.firstOrNull?.value; // That function will break if i decide to recive other leaderboards // TODO: Think about better solution } /// Retrieves and returns 100 latest news entries from Tetra Channel api for given [userID]. Throws an exception if fails to retrieve. Future> fetchNews(String userID) async{ try{ var cached = _newsCache.entries.firstWhere((element) => element.value[0].stream == "user_$userID"); if (DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true).isAfter(DateTime.now())){ developer.log("fetchNews: News for $userID retrieved from cache, that expires ${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)}", name: "services/tetrio_crud"); return cached.value; }else{ _newsCache.remove(cached.key); developer.log("fetchNews: Cached news for $userID expired (${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)})", name: "services/tetrio_crud"); } }catch(e){ developer.log("fetchNews: Trying to retrieve news for $userID", name: "services/tetrio_crud"); } Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "tetrioNews", "user": userID.toLowerCase().trim(), "limit": "100"}); } else { url = Uri.https('ch.tetr.io', 'api/news/user_${userID.toLowerCase().trim()}', {"limit": "100"}); } try { final response = await client.get(url); switch (response.statusCode) { case 200: var payload = jsonDecode(response.body); if (payload['success']) { // if api confirmed that everything ok List news = [for (var entry in payload['data']['news']) News.fromJson(entry)]; _newsCache[payload['cache']['cached_until'].toString()] = news; developer.log("fetchNews: $userID news retrieved and cached", name: "services/tetrio_crud"); return news; } else { developer.log("fetchNews: User dosen't exist", name: "services/tetrio_crud", error: response.body); throw TetrioPlayerNotExist(); } case 403: throw TetrioForbidden(); case 429: throw TetrioTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw TetrioInternalProblem(); default: developer.log("fetchNews: Failed to fetch stream", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } /// Retrieves avaliable Tetra League matches from Tetra Channel api. Returns stream object (fake stream). /// Throws an exception if fails to retrieve. Future fetchTLStream(String userID) async { try{ var cached = _tlStreamsCache.entries.firstWhere((element) => element.value.userId == userID); if (DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true).isAfter(DateTime.now())){ developer.log("fetchTLStream: Stream $userID retrieved from cache, that expires ${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)}", name: "services/tetrio_crud"); return cached.value; }else{ _tlStreamsCache.remove(cached.key); developer.log("fetchTLStream: Cached stream $userID expired (${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)})", name: "services/tetrio_crud"); } }catch(e){ developer.log("fetchTLStream: Trying to retrieve stream $userID", name: "services/tetrio_crud"); } Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "tetrioUserTL", "user": userID.toLowerCase().trim()}); } else { url = Uri.https('ch.tetr.io', 'api/streams/league_userrecent_${userID.toLowerCase().trim()}'); } try { final response = await client.get(url); switch (response.statusCode) { case 200: if (jsonDecode(response.body)['success']) { TetraLeagueAlphaStream stream = TetraLeagueAlphaStream.fromJson(jsonDecode(response.body)['data']['records'], userID); _tlStreamsCache[jsonDecode(response.body)['cache']['cached_until'].toString()] = stream; developer.log("fetchTLStream: $userID stream retrieved and cached", name: "services/tetrio_crud"); return stream; } else { developer.log("fetchTLStream User dosen't exist", name: "services/tetrio_crud", error: response.body); throw TetrioPlayerNotExist(); } case 403: throw TetrioForbidden(); case 429: throw TetrioTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw TetrioInternalProblem(); default: developer.log("fetchTLStream Failed to fetch stream", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } /// Saves Tetra League Matches from [stream] to the local DB. Future saveTLMatchesFromStream(TetraLeagueAlphaStream stream) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); for (TetraLeagueAlphaRecord match in stream.records) { // putting then one by one final results = await db.query(tetraLeagueMatchesTable, where: '$replayID = ?', whereArgs: [match.replayId]); if (results.isNotEmpty) continue; // if match alreay exist - skip db.insert(tetraLeagueMatchesTable, { idCol: match.ownId, replayID: match.replayId, timestamp: match.timestamp.toString(), player1id: match.endContext.first.userId, player2id: match.endContext.last.userId, endContext1: jsonEncode(match.endContext.first.toJson()), endContext2: jsonEncode(match.endContext.last.toJson()) }); } } /// Deletes duplicate entries of Tetra League matches from local DB. Future removeDuplicatesFromTLMatches() async{ await ensureDbIsOpen(); final db = getDatabaseOrThrow(); await db.execute(""" DELETE FROM $tetraLeagueMatchesTable WHERE $idCol IN ( SELECT $idCol FROM ( SELECT $idCol, ROW_NUMBER() OVER ( PARTITION BY $replayID ORDER BY $replayID) AS row_num FROM $tetraLeagueMatchesTable ) t WHERE row_num > 1 ); """); } /// Gets and returns a list of matches from local DB for a given [playerID]. Future> getTLMatchesbyPlayerID(String playerID) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); List matches = []; final results = await db.query(tetraLeagueMatchesTable, where: '($player1id = ?) OR ($player2id = ?)', whereArgs: [playerID, playerID]); for (var match in results){ matches.add(TetraLeagueAlphaRecord( ownId: match[idCol].toString(), replayId: match[replayID].toString(), timestamp: DateTime.parse(match[timestamp].toString()), endContext:[ EndContextMulti.fromJson(jsonDecode(match[endContext1].toString())), EndContextMulti.fromJson(jsonDecode(match[endContext2].toString())) ], replayAvalable: false )); } return matches; } /// Gets and returns an amount of stored Tetra League mathes between [ourPlayerID] and [enemyPlayerID]. Future getNumberOfTLMatchesBetweenPlayers(String ourPlayerID, String enemyPlayerID) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final results = await db.rawQuery("SELECT COUNT(*) from tetrioAlphaLeagueMathces WHERE (player1id = $ourPlayerID AND player2id = $enemyPlayerID) OR (player1id = $enemyPlayerID AND player2id = $ourPlayerID)"); return results.first.values.first as int; } /// Deletes match and stats of that match with given [matchID] from local DB. Throws an exception if fails. Future deleteTLMatch(String matchID) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final rID = (await db.query(tetraLeagueMatchesTable, where: '$idCol = ?', whereArgs: [matchID])).first[replayID]; final results = await db.delete(tetraLeagueMatchesTable, where: '$idCol = ?', whereArgs: [matchID]); if (results != 1) { throw CouldNotDeleteMatch(); } await db.delete(tetrioTLReplayStatsTable, where: '$idCol = ?', whereArgs: [rID]); } /// Retrieves Blitz, 40 Lines and Zen records for a given [userID] from Tetra Channel api. Returns Map, which contains user id (`user`), /// Blitz (`blitz`) and 40 Lines (`sprint`) record objects and Zen object (`zen`). Throws an exception if fails to retrieve. Future> fetchRecords(String userID) async { try{ var cached = _recordsCache.entries.firstWhere((element) => element.value['user'] == userID); if (DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true).isAfter(DateTime.now())){ developer.log("fetchRecords: $userID records retrieved from cache, that expires ${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)}", name: "services/tetrio_crud"); return cached.value; }else{ _recordsCache.remove(cached.key); developer.log("fetchRecords: $userID records expired (${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)})", name: "services/tetrio_crud"); } }catch(e){ developer.log("fetchRecords: Trying to retrieve $userID records", name: "services/tetrio_crud"); } Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "tetrioUserRecords", "user": userID.toLowerCase().trim()}); } else { url = Uri.https('ch.tetr.io', 'api/users/${userID.toLowerCase().trim()}/records'); } try{ final response = await client.get(url); switch (response.statusCode) { case 200: if (jsonDecode(response.body)['success']) { Map jsonRecords = jsonDecode(response.body); var sprint = jsonRecords['data']['records']['40l']['record'] != null ? RecordSingle.fromJson(jsonRecords['data']['records']['40l']['record'], jsonRecords['data']['records']['40l']['rank']) : null; var blitz = jsonRecords['data']['records']['blitz']['record'] != null ? RecordSingle.fromJson(jsonRecords['data']['records']['blitz']['record'], jsonRecords['data']['records']['blitz']['rank']) : null; var zen = TetrioZen.fromJson(jsonRecords['data']['zen']); Map map = {"user": userID.toLowerCase().trim(), "sprint": sprint, "blitz": blitz, "zen": zen}; _recordsCache[jsonDecode(response.body)['cache']['cached_until'].toString()] = map; developer.log("fetchRecords: $userID records retrieved and cached", name: "services/tetrio_crud"); return map; } else { developer.log("fetchRecords User dosen't exist", name: "services/tetrio_crud", error: response.body); throw TetrioPlayerNotExist(); } case 403: throw TetrioForbidden(); case 429: throw TetrioTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw TetrioInternalProblem(); default: developer.log("fetchRecords Failed to fetch records", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } /// Creates an entry in local DB for [tetrioPlayer]. Throws an exception if that player already here. Future createPlayer(TetrioPlayer tetrioPlayer) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); // checking if its already here final results = await db.query(tetrioUsersTable, limit: 1, where: '$idCol = ?', whereArgs: [tetrioPlayer.userId.toLowerCase()]); if (results.isNotEmpty) { throw TetrioPlayerAlreadyExist(); } // converting to json and store final Map statesJson = {(tetrioPlayer.state.millisecondsSinceEpoch ~/ 1000).toString(): tetrioPlayer.toJson()}; db.insert(tetrioUsersTable, {idCol: tetrioPlayer.userId, nickCol: tetrioPlayer.username, statesCol: jsonEncode(statesJson)}); _players.addEntries({tetrioPlayer.userId: tetrioPlayer.username}.entries); _tetrioStreamController.add(_players); } /// Adds user id of [tetrioPlayer] to the [tetrioUsersToTrackTable] of database. Future addPlayerToTrack(TetrioPlayer tetrioPlayer) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final results = await db.query(tetrioUsersToTrackTable, where: '$idCol = ?', whereArgs: [tetrioPlayer.userId.toLowerCase()]); if (results.isNotEmpty) { throw TetrioPlayerAlreadyExist(); } db.insert(tetrioUsersToTrackTable, {idCol: tetrioPlayer.userId}); } /// Returns bool, which tells whether is given [id] is in [tetrioUsersToTrackTable]. Future isPlayerTracking(String id) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final results = await db.query(tetrioUsersToTrackTable, where: '$idCol = ?', whereArgs: [id.toLowerCase()]); return results.isNotEmpty; } /// Returns Iterable with user ids of players who is tracked. Future> getAllPlayerToTrack() async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final players = await db.query(tetrioUsersToTrackTable); return players.map((noteRow) => noteRow["id"].toString()); } /// Removes user with given [id] from the [tetrioUsersToTrackTable] of database. Future deletePlayerToTrack(String id) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final deletedPlayer = await db.delete(tetrioUsersToTrackTable, where: '$idCol = ?', whereArgs: [id.toLowerCase()]); if (deletedPlayer != 1) { throw CouldNotDeletePlayer(); } else { _players.removeWhere((key, value) => key == id); _tetrioStreamController.add(_players); } } /// Saves state (which is [tetrioPlayer]) to the local database. Future storeState(TetrioPlayer tetrioPlayer) async { // if tetrio player doesn't have entry in database - just calling different function List states = await getPlayer(tetrioPlayer.userId); if (states.isEmpty) { await createPlayer(tetrioPlayer); return; } // we not going to add state, that is same, as the previous bool test = states.last.isSameState(tetrioPlayer); if (test == false) states.add(tetrioPlayer); // Making map of the states final Map statesJson = {}; for (var e in states) { // Saving in format: {"unix_seconds": json_of_state} statesJson.addEntries({(e.state.millisecondsSinceEpoch ~/ 1000).toString(): e.toJson()}.entries); } // Rewrite our database await ensureDbIsOpen(); final db = getDatabaseOrThrow(); await db.update(tetrioUsersTable, {idCol: tetrioPlayer.userId, nickCol: tetrioPlayer.username, statesCol: jsonEncode(statesJson)}, where: '$idCol = ?', whereArgs: [tetrioPlayer.userId]); } /// Remove state (which is [tetrioPlayer]) from the local database Future deleteState(TetrioPlayer tetrioPlayer) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); List states = await getPlayer(tetrioPlayer.userId); // removing state from map that contain every state of each user states.removeWhere((element) => element.state == tetrioPlayer.state); // Making map of the states (without deleted one) final Map statesJson = {}; for (var e in states) { statesJson.addEntries({(e.state.millisecondsSinceEpoch ~/ 1000).toString(): e.toJson()}.entries); } // Rewriting database entry with new json await db.update(tetrioUsersTable, {idCol: tetrioPlayer.userId, nickCol: tetrioPlayer.username, statesCol: jsonEncode(statesJson)}, where: '$idCol = ?', whereArgs: [tetrioPlayer.userId]); _tetrioStreamController.add(_players); } /// Returns list of all states of player with given [id] from database. Can return empty list if player /// was not found. Future> getPlayer(String id) async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); List states = []; final results = await db.query(tetrioUsersTable, limit: 1, where: '$idCol = ?', whereArgs: [id.toLowerCase()]); if (results.isEmpty) { return states; // it empty } else { dynamic rawStates = results.first['jsonStates'] as String; rawStates = json.decode(rawStates); // recreating objects of states rawStates.forEach((k, v) => states.add(TetrioPlayer.fromJson(v, DateTime.fromMillisecondsSinceEpoch(int.parse(k) * 1000), id, results.first[nickCol] as String))); // updating the stream _players.removeWhere((key, value) => key == id); _players.addEntries({states.last.userId: states.last.username}.entries); _tetrioStreamController.add(_players); return states; } } /// Retrieves general stats of [user] (nickname or id) from Tetra Channel api. Returns [TetrioPlayer] object of this user. /// If [isItDiscordID] is true, function expects [user] to be a discord user id. Throws an exception if fails to retrieve. Future fetchPlayer(String user, {bool isItDiscordID = false}) async { try{ var cached = _playersCache.entries.firstWhere((element) => element.value.userId == user || element.value.username == user); if (DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true).isAfter(DateTime.now())){ developer.log("fetchPlayer: User $user retrieved from cache, that expires ${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)}", name: "services/tetrio_crud"); return cached.value; }else{ _playersCache.remove(cached.key); developer.log("fetchPlayer: Cached user $user expired (${DateTime.fromMillisecondsSinceEpoch(int.parse(cached.key.toString()), isUtc: true)})", name: "services/tetrio_crud"); } }catch(e){ developer.log("fetchPlayer: Trying to retrieve $user", name: "services/tetrio_crud"); } if (isItDiscordID){ // trying to find player with given discord id Uri dUrl; if (kIsWeb) { dUrl = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "tetrioUserByDiscordID", "user": user.toLowerCase().trim()}); } else { dUrl = Uri.https('ch.tetr.io', 'api/users/search/${user.toLowerCase().trim()}'); } try{ final response = await client.get(dUrl); switch (response.statusCode) { case 200: var json = jsonDecode(response.body); if (json['success'] && json['data'] != null) { // success - rewrite user with tetrio user id and going to obtain data about him user = json['data']['user']['_id']; } else { // fail - throw an exception developer.log("fetchPlayer User dosen't exist", name: "services/tetrio_crud", error: response.body); throw TetrioDiscordNotExist(); } break; // more exceptions to god of exceptions case 403: throw TetrioForbidden(); case 429: throw TetrioTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw TetrioInternalProblem(); default: developer.log("fetchPlayer Failed to fetch player", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } } on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } // finally going to obtain Uri url; if (kIsWeb) { url = Uri.https('ts.dan63.by', 'oskware_bridge.php', {"endpoint": "tetrioUser", "user": user.toLowerCase().trim()}); } else { url = Uri.https('ch.tetr.io', 'api/users/${user.toLowerCase().trim()}'); } try{ final response = await client.get(url); switch (response.statusCode) { case 200: var json = jsonDecode(response.body); if (json['success']) { // parse and count stats TetrioPlayer player = TetrioPlayer.fromJson(json['data']['user'], DateTime.fromMillisecondsSinceEpoch(json['cache']['cached_at'], isUtc: true), json['data']['user']['_id'], json['data']['user']['username']); _playersCache[jsonDecode(response.body)['cache']['cached_until'].toString()] = player; developer.log("fetchPlayer: $user retrieved and cached", name: "services/tetrio_crud"); return player; } else { developer.log("fetchPlayer User dosen't exist", name: "services/tetrio_crud", error: response.body); throw TetrioPlayerNotExist(); } case 403: throw TetrioForbidden(); case 429: throw TetrioTooManyRequests(); case 418: throw TetrioOskwareBridgeProblem(); case 500: case 502: case 503: case 504: throw TetrioInternalProblem(); default: developer.log("fetchPlayer Failed to fetch player", name: "services/tetrio_crud", error: response.statusCode); throw ConnectionIssue(response.statusCode, response.reasonPhrase??"No reason"); } }on http.ClientException catch (e, s) { developer.log("$e, $s"); throw http.ClientException(e.message, e.uri); } } /// Retrieves whole [tetrioUsersTable] and returns Map with [TetrioPlayer] objects of everyone in database Future>> getAllPlayers() async { await ensureDbIsOpen(); final db = getDatabaseOrThrow(); final players = await db.query(tetrioUsersTable); Map> data = {}; for (var entry in players){ var test = json.decode(entry['jsonStates'] as String); List states = []; test.forEach((k, v) => states.add(TetrioPlayer.fromJson(v, DateTime.fromMillisecondsSinceEpoch(int.parse(k) * 1000), entry[idCol] as String, entry[nickCol] as String))); data.addEntries({states.last.userId: states}.entries); } return data; } }