From 7f16a9f3deca61ad782d1a22a9a04bf08f76fce0 Mon Sep 17 00:00:00 2001 From: gthvmt Date: Mon, 18 Dec 2023 18:44:27 +0000 Subject: [PATCH] :sparkles: display dialog on uncaught errors display a dialog on all errors which prompts the user to open a github issue. I filtered two errors that relate to loading emote images as these show up quite frequently and are already known. --- src/android/build.gradle | 2 +- src/lib/main.dart | 98 ++++++++++++++++++++++++++++++- src/lib/models/seventv.dart | 16 +++-- src/lib/models/whatsapp.dart | 6 +- src/lib/screens/browser.dart | 22 ++++--- src/lib/screens/stickerpack.dart | 4 +- src/lib/screens/stickerpacks.dart | 1 - src/pubspec.lock | 64 ++++++++++++++++++++ src/pubspec.yaml | 1 + 9 files changed, 193 insertions(+), 21 deletions(-) diff --git a/src/android/build.gradle b/src/android/build.gradle index 55703eb..286ac86 100644 --- a/src/android/build.gradle +++ b/src/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.7.10' + ext.kotlin_version = '1.9.21' repositories { google() mavenCentral() diff --git a/src/lib/main.dart b/src/lib/main.dart index ecd0727..fc9b9c1 100644 --- a/src/lib/main.dart +++ b/src/lib/main.dart @@ -1,12 +1,103 @@ +import 'dart:math'; +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:seventv_for_whatsapp/screens/browser.dart'; -// import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:url_launcher/url_launcher.dart'; + +final GlobalKey _navigatorKey = GlobalKey(); +var _errorDialogIsVisible = false; void main() { + FlutterError.onError = (details) { + FlutterError.presentError(details); + + final error = details.exception.toString().toLowerCase(); + + // filter known errors (which should eventually be fixed...) + if ( + // Some emote images, for example this one https://7tv.app/emotes/64789a4f0c7cd505faf2e7f6 + // fail to load because of some error involving a frame. + // Probably only fixable by writing a custom ImageProvider(?) + error.contains('could not getpixels for frame ') || + // Sometimes loading an image from the 7TV API just fails. Usually resolved by retrying. + // There is a NetworkImageWithRetry in the flutter_image package + // (https://github.com/flutter/packages/blob/main/packages/flutter_image/lib/network.dart) + // but it does not support animatied images which makes it kinda useless for us. + // might be a good starting point for our custom ImageProvider which should address the + // above error as well. + error.contains('connection closed while receiving data, uri =') || + error.contains('connection reset by peer, uri =') + ) { + return; + } + + showErrorDialog(details.exceptionAsString(), details.stack); + }; + PlatformDispatcher.instance.onError = (error, stack) { + showErrorDialog(error.toString(), stack); + return true; + }; + runApp(const MyApp()); } +Future showErrorDialog(String errorDescription, StackTrace? stackTrace) async { + if (_errorDialogIsVisible || _navigatorKey.currentState?.overlay == null) { + return; + } + _errorDialogIsVisible = true; + const issueTitleMaxLength = 125; + + var issueTitle = errorDescription.length <= issueTitleMaxLength + ? errorDescription + : "${errorDescription.substring(0, issueTitleMaxLength - 3)}..."; + final issueBody = '### Exception:\n```\n$errorDescription\n```' + '${stackTrace?.toString().isEmpty ?? true ? '' : '\n\n### Stacktrace:\n```\n$stackTrace\n```'}\n### Steps to reproduce:\n'; + final newIssueUri = Uri.parse('https://github.com/gthvmt/7TV-for-WhatsApp/issues/new' + '?title=${Uri.encodeComponent(issueTitle)}' + '&body=${Uri.encodeComponent(issueBody)}'); + + await showDialog( + context: _navigatorKey.currentState!.overlay!.context, + builder: (context) => AlertDialog( + title: const Text('Oops!'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Looks like you\'ve run into an error:'), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5), + constraints: + BoxConstraints(maxHeight: max(MediaQuery.of(context).size.height * .4, 50)), + width: double.infinity, + child: Scrollbar( + child: SingleChildScrollView( + child: Text( + errorDescription + + (stackTrace?.toString().isEmpty ?? true + ? '' + : '\n\nStacktrace:\n$stackTrace'), + textAlign: TextAlign.left, + style: TextStyle(color: Theme.of(context).colorScheme.error))), + ), + ), + const SizedBox(height: 15), + const Text('Feel free to open up a github issue.' + '(Please make sure there aren\'t any similar issues open already 😅)') + ], + ), + actions: [ + TextButton( + onPressed: () async => await launchUrl(newIssueUri), + child: const Text('Open an issue')), + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close')) + ], + )); + _errorDialogIsVisible = false; +} + class MyApp extends StatelessWidget { const MyApp({super.key}); @@ -17,11 +108,12 @@ class MyApp extends StatelessWidget { [DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]); var theme = ThemeData.from( - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0x00f49236), brightness: Brightness.dark), + colorScheme: + ColorScheme.fromSeed(seedColor: const Color(0x00f49236), brightness: Brightness.dark), useMaterial3: true); return MaterialApp( + navigatorKey: _navigatorKey, title: '7TV for WhatsApp', theme: theme, home: const Browser(), diff --git a/src/lib/models/seventv.dart b/src/lib/models/seventv.dart index 86e43bb..fa64c6c 100644 --- a/src/lib/models/seventv.dart +++ b/src/lib/models/seventv.dart @@ -4,7 +4,8 @@ import 'dart:convert'; import 'dart:async'; class SevenTv { - static const _searchEmotesQuery = r''' + static const _searchEmotesQuery = + r''' query SearchEmotes($query: String!, $page: Int, $sort: Sort, $limit: Int, $filter: EmoteSearchFilter) { emotes(query: $query, page: $page, sort: $sort, limit: $limit, filter: $filter) { count @@ -43,7 +44,7 @@ class SevenTv { '''; final _url = Uri.parse('https://7tv.io/v3/gql'); - final _client = HttpClient(); + final client = HttpClient(); Stream> buildSearchRequests(Map args) async* { int countCollected = 0; @@ -51,7 +52,7 @@ class SevenTv { int currentPage = 1; while (countTotal < 0 || countTotal > countCollected) { args['page'] = currentPage; - final req = await _client.postUrl(_url); + final req = await client.postUrl(_url); req.headers.add(HttpHeaders.contentTypeHeader, ContentType.json.mimeType); req.write(jsonEncode({'query': _searchEmotesQuery, 'variables': args})); final resp = await req.close(); @@ -202,8 +203,11 @@ class Emote { return data; } - Uri getMaxSizeUrl({Format format = Format.webp}) => host!.getUrl( - host!.files!.where((f) => f.format == format).reduce((a, b) => a.height > b.height ? a : b)); + Uri? getMaxSizeUrl({Format format = Format.webp}) { + final files = host?.files?.where((f) => f.format == format); + final file = files?.isEmpty ?? true ? null : files?.reduce((a, b) => a.height > b.height ? a : b); + return file == null ? null : host?.getUrl(file); + } File getMaxSizeFile({Format format = Format.webp}) => host!.files!.reduce((a, b) => a.height > b.height ? a : b); @@ -292,7 +296,7 @@ class Host { throw "url does not lead to a valid webp"; } // Check if the file is animated by looking for the "ANIM" chunk identifier - final isAnimated = listEquals(bytes.sublist(30, 30 + 4), utf8.encode('ANIM') as Uint8List); + final isAnimated = listEquals(bytes.sublist(30, 30 + 4), utf8.encode('ANIM')); return isAnimated; } } diff --git a/src/lib/models/whatsapp.dart b/src/lib/models/whatsapp.dart index 72db70c..3304584 100644 --- a/src/lib/models/whatsapp.dart +++ b/src/lib/models/whatsapp.dart @@ -184,7 +184,11 @@ class Sticker { static Future fromEmote(Emote emote, List emojis) async { final identifier = Ulid(); final httpClient = io.HttpClient(); - final request = await httpClient.getUrl(emote.getMaxSizeUrl()); + final url = emote.getMaxSizeUrl(); + if (url == null) { + throw Exception('Unable to get image URL from emote'); + } + final request = await httpClient.getUrl(url); final response = await request.close(); final imagePath = '${(await WhatsApp.getStickerDirectory()).path}/$identifier.webp'; diff --git a/src/lib/screens/browser.dart b/src/lib/screens/browser.dart index a1534f6..5161576 100644 --- a/src/lib/screens/browser.dart +++ b/src/lib/screens/browser.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:seventv_for_whatsapp/models/settings.dart'; @@ -9,7 +8,6 @@ import 'package:seventv_for_whatsapp/services/notification_service.dart'; import 'package:seventv_for_whatsapp/widgets/create_stickerpack_dialog.dart'; import 'package:seventv_for_whatsapp/widgets/emote_emoji_picker.dart'; import 'package:shimmer/shimmer.dart'; -import 'package:universal_io/io.dart'; import '../models/whatsapp.dart'; import 'stickerpack.dart' as views; @@ -157,7 +155,7 @@ class _BrowserState extends State { //TODO: minor performance improvement if we download the full webp in the background here after returning whether //it is animated or not and then pass it when creating the Sticker final emoteIsAnimated = await emote.host!.checkIfAnimated(emote.getMaxSizeFile()); - debugPrint('emote is animated: ${emoteIsAnimated}'); + debugPrint('emote is animated: $emoteIsAnimated'); if (!mounted) { return; } @@ -282,7 +280,9 @@ class _BrowserState extends State { onTap: () => _emoteTapped(emote), child: Stack( children: [ - Center(child: Image.network(emote.getMaxSizeUrl().toString())), + Center( + child: _buildEmoteImage(emote), + ), Align( alignment: Alignment.bottomCenter, child: Text( @@ -307,16 +307,24 @@ class _BrowserState extends State { ), ), ); + + Widget _buildEmoteImage(Emote emote) { + final url = emote.getMaxSizeUrl(); + if (url == null) { + // TODO: should probably not list these broken emotes alltogether + return const Placeholder(); + } + return Image.network(url.toString()); + } } class Skeleton extends StatelessWidget { const Skeleton({ - Key? key, + super.key, required SliverGridDelegateWithMaxCrossAxisExtent gridDelegate, required int searchCunkSize, }) : _gridDelegate = gridDelegate, - _searchCunkSize = searchCunkSize, - super(key: key); + _searchCunkSize = searchCunkSize; final SliverGridDelegateWithMaxCrossAxisExtent _gridDelegate; final int _searchCunkSize; diff --git a/src/lib/screens/stickerpack.dart b/src/lib/screens/stickerpack.dart index ab0226b..e31d7b0 100644 --- a/src/lib/screens/stickerpack.dart +++ b/src/lib/screens/stickerpack.dart @@ -85,8 +85,8 @@ class _StickerPackState extends State { backgroundColor: !isValidPack ? const Color.fromARGB(255, 120, 120, 120) : null, foregroundColor: !isValidPack ? const Color.fromARGB(255, 70, 70, 70) : null, onPressed: !isValidPack ? null : () => _addToWhatsApp(widget.stickerPack), - label: Row( - children: const [Icon(Icons.add), SizedBox(width: 2), Text('Add to WhatsApp')], + label: const Row( + children: [Icon(Icons.add), SizedBox(width: 2), Text('Add to WhatsApp')], )), ); } diff --git a/src/lib/screens/stickerpacks.dart b/src/lib/screens/stickerpacks.dart index 953423f..ccc86b6 100644 --- a/src/lib/screens/stickerpacks.dart +++ b/src/lib/screens/stickerpacks.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:seventv_for_whatsapp/models/whatsapp.dart'; import 'package:seventv_for_whatsapp/widgets/create_stickerpack_dialog.dart'; -import 'package:seventv_for_whatsapp/services/notification_service.dart'; class StickerPackSelectedCallbackResult { final bool reloadRequired; diff --git a/src/pubspec.lock b/src/pubspec.lock index 3abbff7..f2e717a 100644 --- a/src/pubspec.lock +++ b/src/pubspec.lock @@ -862,6 +862,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: e9aa5ea75c84cf46b3db4eea212523591211c3cf2e13099ee4ec147f54201c86 + url: "https://pub.dev" + source: hosted + version: "6.2.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "31222ffb0063171b526d3e569079cf1f8b294075ba323443fdc690842bfd4def" + url: "https://pub.dev" + source: hosted + version: "6.2.0" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: bba3373219b7abb6b5e0d071b0fe66dfbe005d07517a68e38d4fc3638f35c6d3 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 + url: "https://pub.dev" + source: hosted + version: "3.1.0" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "980e8d9af422f477be6948bdfb68df8433be71f5743a188968b0c1b887807e50" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "7286aec002c8feecc338cc33269e96b73955ab227456e9fb2a91f7fab8a358e9" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + url: "https://pub.dev" + source: hosted + version: "3.1.1" uuid: dependency: transitive description: diff --git a/src/pubspec.yaml b/src/pubspec.yaml index fe41deb..2cc6e3f 100644 --- a/src/pubspec.yaml +++ b/src/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: flutter_local_notifications: ^16.2.0 json_annotation: ^4.8.0 get_it: ^7.2.0 + url_launcher: ^6.2.2 dev_dependencies: flutter_test: sdk: flutter