From eca63a5288a9a720324825c7cc9f80c63f777f8c Mon Sep 17 00:00:00 2001 From: dan63047 Date: Thu, 19 Oct 2023 00:50:41 +0300 Subject: [PATCH] Replay stealing (excluding web version) --- lib/data_objects/tetrio.dart | 4 ++- lib/services/crud_exceptions.dart | 10 ++++++ lib/services/tetrio_crud.dart | 43 ++++++++++++++++++++++-- lib/utils/open_in_browser.dart | 10 ++++++ lib/views/main_view.dart | 7 ++-- lib/views/settings_view.dart | 13 ++------ lib/views/tl_match_view.dart | 54 +++++++++++++++++++++++++++++++ 7 files changed, 123 insertions(+), 18 deletions(-) create mode 100644 lib/utils/open_in_browser.dart diff --git a/lib/data_objects/tetrio.dart b/lib/data_objects/tetrio.dart index f8c5104..f2a03ff 100644 --- a/lib/data_objects/tetrio.dart +++ b/lib/data_objects/tetrio.dart @@ -728,15 +728,17 @@ class TetraLeagueAlphaRecord{ late String replayId; late String ownId; late DateTime timestamp; + late bool replayAvalable; late List endContext; - TetraLeagueAlphaRecord({required this.replayId, required this.ownId, required this.timestamp, required this.endContext}); + TetraLeagueAlphaRecord({required this.replayId, required this.ownId, required this.timestamp, required this.endContext, required this.replayAvalable}); TetraLeagueAlphaRecord.fromJson(Map json) { ownId = json['_id']; endContext = [EndContextMulti.fromJson(json['endcontext'][0]), EndContextMulti.fromJson(json['endcontext'][1])]; replayId = json['replayid']; timestamp = DateTime.parse(json['ts']); + replayAvalable = true; } Map toJson() { diff --git a/lib/services/crud_exceptions.dart b/lib/services/crud_exceptions.dart index 2fa735c..b019bd4 100644 --- a/lib/services/crud_exceptions.dart +++ b/lib/services/crud_exceptions.dart @@ -24,8 +24,18 @@ class P1nkl0bst3rTooManyRequests implements Exception {} class P1nkl0bst3rForbidden implements Exception {} +class SzyTooManyRequests implements Exception {} + +class SzyForbidden implements Exception {} + +class SzyNotFound implements Exception {} + +class TetrioReplayAlreadyExist implements Exception {} + class P1nkl0bst3rInternalProblem implements Exception {} +class SzyInternalProblem implements Exception {} + class TetrioOskwareBridgeProblem implements Exception {} class TetrioInternalProblem implements Exception {} diff --git a/lib/services/tetrio_crud.dart b/lib/services/tetrio_crud.dart index 1ca509c..0f10f43 100644 --- a/lib/services/tetrio_crud.dart +++ b/lib/services/tetrio_crud.dart @@ -1,6 +1,8 @@ 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/main.dart' show packageInfo; import 'package:flutter/foundation.dart'; import 'package:tetra_stats/services/custom_http_client.dart'; @@ -57,7 +59,8 @@ class TetrioService extends DB { final Map> _newsCache = {}; final Map> _topTRcache = {}; final Map _tlStreamsCache = {}; // i'm trying to respect oskware api It should look something like {"cached_until": TetrioPlayer} - final client = UserAgentClient("Tetra Stats v${packageInfo.version} (dm @dan63047 if someone abuse that software)", http.Client()); + // final client = UserAgentClient("Tetra Stats v${packageInfo.version} (dm @dan63047 if someone abuse that software)", http.Client()); + final client = UserAgentClient("Kagari-chan loves osk (Tetra Stats dev build)", http.Client()); static final TetrioService _shared = TetrioService._sharedInstance(); factory TetrioService() => _shared; late final StreamController>> _tetrioStreamController; @@ -107,6 +110,42 @@ class TetrioService extends DB { } } + Future szyDownloadAndSaveReplay(String replayID) async { + Uri url = Uri.https('inoue.szy.lol', '/api/replay/$replayID'); + 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(); + try{ + final response = await client.get(url); + + switch (response.statusCode) { + case 200: + await replayFile.writeAsBytes(response.bodyBytes); + return replayFile.path; + 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("szyDownloadAndSaveReplay: 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) { + developer.log("$e, $s"); + throw http.ClientException(e.message, e.uri); + } + } + Future fetchTopTR(String id) async { try{ var cached = _topTRcache.entries.firstWhere((element) => element.value.keys.first == id); @@ -441,7 +480,7 @@ class TetrioService extends DB { 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()))])); + 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; } diff --git a/lib/utils/open_in_browser.dart b/lib/utils/open_in_browser.dart new file mode 100644 index 0000000..d63cf0d --- /dev/null +++ b/lib/utils/open_in_browser.dart @@ -0,0 +1,10 @@ +import 'package:url_launcher/url_launcher.dart'; + +Future launchInBrowser(Uri url) async { + if (!await launchUrl( + url, + mode: LaunchMode.externalApplication, + )) { + throw Exception('Could not launch $url'); + } + } \ No newline at end of file diff --git a/lib/views/main_view.dart b/lib/views/main_view.dart index f2343d0..36485ed 100644 --- a/lib/views/main_view.dart +++ b/lib/views/main_view.dart @@ -159,11 +159,12 @@ class _MainState extends State with SingleTickerProviderStateMixin { List states = []; TetraLeagueAlpha? compareWith; var uniqueTL = {}; + tlMatches = tlStream.records; if (isTracking){ await teto.storeState(me); await teto.saveTLMatchesFromStream(tlStream); - tlMatches.addAll(await teto.getTLMatchesbyPlayerID(me.userId)); - for (var match in tlStream.records) { + var storedRecords = await teto.getTLMatchesbyPlayerID(me.userId); + for (var match in storedRecords) { if (!tlMatches.contains(match)) tlMatches.add(match); } tlMatches.sort((a, b) { @@ -172,8 +173,6 @@ class _MainState extends State with SingleTickerProviderStateMixin { if(a.timestamp.isAfter(b.timestamp)) return -1; return 0; }); - } else{ - tlMatches = tlStream.records; } if(fetchHistory) await teto.fetchAndsaveTLHistory(_searchFor); states.addAll(await teto.getPlayer(me.userId)); diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart index e47cb27..14842d0 100644 --- a/lib/views/settings_view.dart +++ b/lib/views/settings_view.dart @@ -9,7 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:tetra_stats/gen/strings.g.dart'; import 'package:tetra_stats/services/crud_exceptions.dart'; import 'package:tetra_stats/services/tetrio_crud.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:tetra_stats/utils/open_in_browser.dart'; import 'package:window_manager/window_manager.dart'; late String oldWindowTitle; @@ -43,15 +43,6 @@ class SettingsState extends State { super.dispose(); } - Future _launchInBrowser(Uri url) async { - if (!await launchUrl( - url, - mode: LaunchMode.externalApplication, - )) { - throw Exception('Could not launch $url'); - } - } - Future _getPreferences() async { prefs = await SharedPreferences.getInstance(); _setDefaultNickname(prefs.getString("player")); @@ -248,7 +239,7 @@ class SettingsState extends State { const Divider(), ListTile( onTap: (){ - _launchInBrowser(Uri.https("github.com", "dan63047/TetraStats")); + launchInBrowser(Uri.https("github.com", "dan63047/TetraStats")); }, title: Text(t.aboutApp), subtitle: Text(t.aboutAppText(appName: packageInfo.appName, packageName: packageInfo.packageName, version: packageInfo.version, buildNumber: packageInfo.buildNumber)), diff --git a/lib/views/tl_match_view.dart b/lib/views/tl_match_view.dart index 9273e02..ab759ec 100644 --- a/lib/views/tl_match_view.dart +++ b/lib/views/tl_match_view.dart @@ -1,11 +1,19 @@ +// ignore_for_file: use_build_context_synchronously + import 'dart:io'; +import 'package:tetra_stats/services/crud_exceptions.dart'; + +import 'main_view.dart' show teto; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:tetra_stats/data_objects/tetrio.dart'; import 'package:tetra_stats/gen/strings.g.dart'; +import 'package:tetra_stats/utils/open_in_browser.dart'; import 'package:window_manager/window_manager.dart'; +// ignore: avoid_web_libraries_in_flutter +// import 'dart:html' show AnchorElement, document; final DateFormat dateFormat = DateFormat.yMMMd(LocaleSettings.currentLocale.languageCode).add_Hms(); @@ -52,6 +60,52 @@ class TlMatchResultState extends State { return Scaffold( appBar: AppBar( title: Text("${widget.record.endContext.firstWhere((element) => element.userId == widget.initPlayerId).username.toUpperCase()} ${t.vs} ${widget.record.endContext.firstWhere((element) => element.userId != widget.initPlayerId).username.toUpperCase()} ${t.inTLmatch} ${dateFormat.format(widget.record.timestamp)}"), + actions: [ + PopupMenuButton( + enabled: widget.record.replayAvalable, + itemBuilder: (BuildContext context) => [ + const PopupMenuItem( + value: 1, + child: Text("Download le replay"), + ), + const PopupMenuItem( + value: 2, + child: Text("Open le replay in TETR.IO"), + ), + ], + onSelected: (value) async { + switch (value) { + case 1: + if (kIsWeb){ + // final _base64 = base64Encode([1,2,3,4,5]); + // final anchor = AnchorElement(href: 'data:application/octet-stream;base64,$_base64')..target = 'blank'; + //final anchor = AnchorElement(href: 'https://inoue.szy.lol/api/replay/${widget.record.replayId}')..target = 'blank'; + //anchor.download = "${widget.record.replayId}.ttrm"; + //document.body!.append(anchor); + //anchor.click(); + //anchor.remove(); + } else{ + try{ + String path = await teto.szyDownloadAndSaveReplay(widget.record.replayId); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Replay saved to $path"))); + } on TetrioReplayAlreadyExist{ + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Replay already saved"))); + } on SzyNotFound { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Replay expired (i think)"))); + } on SzyForbidden { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Request has been rejected"))); + } on SzyTooManyRequests { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.errors.tooManyRequests))); + } + } + break; + case 2: + await launchInBrowser(Uri.parse("https://tetr.io/#r:${widget.record.replayId}")); + break; + default: + } + }) + ] ), backgroundColor: Colors.black, body: SafeArea(