import 'dart:io'; import 'package:hash_wallet/di.dart'; import 'package:hash_wallet/entities/preferences_key.dart'; import 'package:hash_wallet/generated/i18n.dart'; import 'package:hash_wallet/main.dart'; import 'package:hash_wallet/src/widgets/alert_with_one_action.dart'; import 'package:hash_wallet/src/widgets/alert_with_two_actions.dart'; import 'package:hash_wallet/store/app_store.dart'; import 'package:hash_wallet/utils/package_info.dart'; import 'package:hash_wallet/utils/show_bar.dart'; import 'package:hash_wallet/utils/show_pop_up.dart'; import 'package:cw_core/root_dir.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; class ExceptionHandler { static bool _hasError = false; static const _coolDownDurationInDays = bool.fromEnvironment('hasDevOptions', defaultValue: kDebugMode || kProfileMode) ? 0 : 7; static File? _file; static Future _saveException(String? error, StackTrace? stackTrace, {String? library}) async { final appDocDir = await getAppDir(); if (_file == null) { _file = File('${appDocDir.path}/error.txt'); } String? walletType; CustomTrace? programInfo; try { walletType = getIt.get().wallet?.type.name; programInfo = CustomTrace(stackTrace ?? StackTrace.current); } catch (_) {} final exception = { "${DateTime.now()}": { "Error": "$error\n\n", "WalletType": "$walletType\n\n", "VerboseLog": "${programInfo?.fileName}#${programInfo?.lineNumber}:${programInfo?.columnNumber} ${programInfo?.callerFunctionName}\n\n", "Library": "$library\n\n", "StackTrace": stackTrace.toString(), } }; const String separator = '''\n\n========================================================== ==========================================================\n\n'''; /// don't save existing errors if (_file!.existsSync()) { final String fileContent = await _file!.readAsString(); if (fileContent.contains("${exception.values.first}")) { return; } } _file!.writeAsStringSync( "$exception $separator", mode: FileMode.append, ); } // Hash Bags: error reports are submitted to our own form at // hash.boats/report. The receiving Node service at hash.boats relays // them to a private Telegram channel and writes them to disk on the // suchwow server — see hash.boats/deploy/install.sh + server.js. // // The original Cake flow used flutter_mailer to hand error.txt to the // OS mail client; that plugin has no Linux backend and most users // don't have a mail client configured, so it rarely worked. We tried // a GitHub-issue prefill next, but that needs a GitHub account most // wallet users won't have. // // The flow now: copy error.txt to the clipboard, open the report // page, user pastes + optionally adds context + submits. No accounts, // no third-party (it's our own backend), works on every platform. static const _reportUrl = 'https://hash.boats/report/'; static void _sendExceptionFile() async { try { if (_file == null) { final appDocDir = await getAppDir(); _file = File('${appDocDir.path}/error.txt'); } await _addDeviceInfo(_file!); final fullBody = await _file!.readAsString(); // Put the full trace on the clipboard so the user can paste it into // the form on hash.boats/report. The receiving server caps each // upload at 200KB — way more than a typical error.txt — so we // don't bother trimming here. await Clipboard.setData(ClipboardData(text: fullBody)); final uri = Uri.parse(_reportUrl); final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); if (!launched) { printV('Could not open report URL: $uri'); return; } // Clear the local error file now that the user has the trace on // their clipboard and the report page is open in their browser. await _file!.writeAsString('', mode: FileMode.write); } catch (e, s) { _saveException(e.toString(), s); } } static Future resetLastPopupDate() async { final sharedPrefs = await SharedPreferences.getInstance(); await sharedPrefs.setString(PreferencesKey.lastPopupDate, DateTime(1971).toString()); } static Future onError(FlutterErrorDetails errorDetails) async { if (await onLedgerError(errorDetails)) return; if (kDebugMode || kProfileMode) { if (_ignoreError(errorDetails.exception.toString()) || _ignoreError(errorDetails.stack.toString())) { printV("(BELOW ERROR IS IGNORED AND WILL NOT TRIGGER POPUP IN PROD)"); } FlutterError.presentError(errorDetails); errorDetails.toString().split("\n").forEach(printV); return; } if (_ignoreError(errorDetails.exception.toString()) || _ignoreError(errorDetails.stack.toString()) || _flutterErrorIgnore(errorDetails)) { return; } _saveException( errorDetails.exceptionAsString(), errorDetails.stack, library: errorDetails.library, ); if (errorDetails.silent) { return; } final sharedPrefs = await SharedPreferences.getInstance(); final lastPopupDate = DateTime.tryParse(sharedPrefs.getString(PreferencesKey.lastPopupDate) ?? '') ?? DateTime.now().subtract(Duration(days: _coolDownDurationInDays + 1)); final durationSinceLastReport = DateTime.now().difference(lastPopupDate).inDays; if (_hasError || durationSinceLastReport < _coolDownDurationInDays) { return; } _hasError = true; await sharedPrefs.setString(PreferencesKey.lastPopupDate, DateTime.now().toString()); // Instead of using WidgetsBinding.instance.addPostFrameCallback we // await Future.delayed(Duration.zero), which does essentially the same ( // but doesn't wait for actual frame to be rendered), but it allows us to // properly await the execution - which is what we want, without awaiting // other code may call functions like Navigator.pop(), and close the alert // instead of the intended UI. // WidgetsBinding.instance.addPostFrameCallback( // (timeStamp) async { await Future.delayed(Duration.zero); if (navigatorKey.currentContext != null) { await showPopUp( context: navigatorKey.currentContext!, builder: (context) { return AlertWithTwoActions( isDividerExist: true, alertTitle: S.of(context).error, alertContent: S.of(context).error_dialog_content, rightButtonText: S.of(context).send, leftButtonText: S.of(context).do_not_send, actionRightButton: () { Navigator.of(context).pop(); _sendExceptionFile(); }, actionLeftButton: () { Navigator.of(context).pop(); }, ); }, ); } _hasError = false; } static const List _ledgerErrors = [ 'Wrong Device Status', 'PlatformException(133, Failed to write: (Unknown Error: 133), null, null)', 'PlatformException(IllegalArgument, Unknown deviceId:', 'ServiceNotSupportedException(ConnectionType.ble, Required service not supported. Write characteristic: false, Notify characteristic: false)', 'Make sure no other program is communicating with the Ledger', 'Exception: 6e01', // Wrong App 'Exception: 6d02', 'Exception: 6511', 'Exception: 6e00', 'Exception: 6985', 'Exception: 5515', ]; static bool isLedgerError(Object exception) => _ledgerErrors.any((element) => exception.toString().contains(element)); static Future onLedgerError(FlutterErrorDetails errorDetails) async { if (!isLedgerError(errorDetails.exception)) return false; String? interpretErrorCode(String errorCode) { if (errorCode.contains("6985")) { return S.current.ledger_error_tx_rejected_by_user; } else if (errorCode.contains("5515")) { return S.current.ledger_error_device_locked; } else if (["6e01", "6d02", "6511", "6e00"].any((e) => errorCode.contains(e))) { return S.current.ledger_error_wrong_app; } return null; } printV(errorDetails.exception); if (navigatorKey.currentContext != null) { await showPopUp( context: navigatorKey.currentContext!, builder: (context) => AlertWithOneAction( alertTitle: "Ledger Error", alertContent: interpretErrorCode(errorDetails.exception.toString()) ?? S.of(context).ledger_connection_error, buttonText: S.of(context).close, buttonAction: () => Navigator.of(context).pop(), ), ); } _hasError = false; return true; } /// Ignore User related errors or system errors static bool _ignoreError(String error) => _ignoredErrors.any((element) => error.contains(element)); static const List _ignoredErrors = const [ "Bad file descriptor", "No space left on device", "OS Error: Broken pipe", "Can't assign requested address", "OS Error: Socket is not connected", "Operation timed out", "No route to host", "Software caused connection abort", "Connection reset by peer", "Connection timed out", "Connection reset by peer", "Connection closed before full header was received", "Connection terminated during handshake", "OS Error: Connection refused, errno = 61", "PERMISSION_NOT_GRANTED", "OS Error: Permission denied", "Failed host lookup:", "CERTIFICATE_VERIFY_FAILED", "Handshake error in client", "Error while launching http", "OS Error: Network is unreachable", "ClientException: Write failed, uri=http", "Corrupted wallets seeds", "bad_alloc", "does not correspond", "basic_string", "input_stream", "input stream error", "invalid signature", "invalid password", "NetworkImage._loadAsync", "SSLV3_ALERT_BAD_RECORD_MAC", "PlatformException(already_active, File picker is already active", // SVG-related errors "SvgParser", "SVG parsing error", "Invalid SVG", "SVG format error", "SvgPicture", "Unable to load asset", // Temporary ignored, More context: Flutter secure storage reads the values as null some times // probably when the device was locked and then opened on Cake // this is solved by a restart of the app // just ignoring until we find a solution to this issue or migrate from flutter secure storage "core/auth_service.dart:92", "core/key_service.dart:14", "Wallet is null", "Wrong Device Status: 0x5515 (UNKNOWN)", "Command handling failed. With error: hostUnreachable", "FocusScopeNode was used after being disposed", "_getDismissibleFlushbar", "_QueuedFuture.execute (package:universal_ble/src/queue.dart:65)", "reown_core/relay_client/websocket/websocket_handler.dart", "Image upload failed due to loss of GPU access", "transport error", "SdkError.sparkError(field0: Operator RPC error: Connection error: status: Unavailable, message: \"dns error\", details: []", "the timeout of the request was reached", "support for coin removed, your seedphrase:" ]; static Future _addDeviceInfo(File file) async { final packageInfo = await PackageInfo.fromPlatform(); final currentVersion = packageInfo.version; final appName = packageInfo.appName; final package = packageInfo.packageName; final deviceInfoPlugin = DeviceInfoPlugin(); Map deviceInfo = {}; if (Platform.isAndroid) { deviceInfo = _readAndroidBuildData(await deviceInfoPlugin.androidInfo); deviceInfo["Platform"] = "Android"; } else if (Platform.isIOS) { deviceInfo = _readIosDeviceInfo(await deviceInfoPlugin.iosInfo); deviceInfo["Platform"] = "iOS"; } else if (Platform.isLinux) { deviceInfo = _readLinuxDeviceInfo(await deviceInfoPlugin.linuxInfo); deviceInfo["Platform"] = "Linux"; } else if (Platform.isMacOS) { deviceInfo = _readMacOsDeviceInfo(await deviceInfoPlugin.macOsInfo); deviceInfo["Platform"] = "MacOS"; } else if (Platform.isWindows) { deviceInfo = _readWindowsDeviceInfo(await deviceInfoPlugin.windowsInfo); deviceInfo["Platform"] = "Windows"; } await file.writeAsString( "App Version: $currentVersion\nApp Name: $appName\nPackage: $package\n\nDevice Info $deviceInfo\n\n", mode: FileMode.append, ); } static Map _readAndroidBuildData(AndroidDeviceInfo build) { return { 'brand': build.brand, 'device': build.device, 'manufacturer': build.manufacturer, 'model': build.model, 'product': build.product, }; } static Map _readIosDeviceInfo(IosDeviceInfo data) { return { 'systemName': data.systemName, 'systemVersion': data.systemVersion, 'model': data.model, 'localizedModel': data.localizedModel, 'isPhysicalDevice': data.isPhysicalDevice, }; } static Map _readLinuxDeviceInfo(LinuxDeviceInfo data) { return { 'name': data.name, 'version': data.version, 'versionCodename': data.versionCodename, 'versionId': data.versionId, 'prettyName': data.prettyName, 'buildId': data.buildId, 'variant': data.variant, 'variantId': data.variantId, }; } static Map _readMacOsDeviceInfo(MacOsDeviceInfo data) { return { 'arch': data.arch, 'model': data.model, 'kernelVersion': data.kernelVersion, 'osRelease': data.osRelease, }; } static Map _readWindowsDeviceInfo(WindowsDeviceInfo data) { return { 'majorVersion': data.majorVersion, 'minorVersion': data.minorVersion, 'buildNumber': data.buildNumber, 'productType': data.productType, 'productName': data.productName, }; } static Future showError(String error, {int? delayInSeconds}) async { if (_hasError) { return; } _hasError = true; if (delayInSeconds != null) { Future.delayed(Duration(seconds: delayInSeconds), () => _showCopyPopup(error)); return; } await Future.delayed(Duration.zero); await _showCopyPopup(error); } static Future _showCopyPopup(String content) async { if (navigatorKey.currentContext != null) { final shouldCopy = await showPopUp( context: navigatorKey.currentContext!, builder: (context) { return AlertWithTwoActions( isDividerExist: true, alertTitle: S.of(context).error, alertContent: content, rightButtonText: S.of(context).copy, leftButtonText: S.of(context).close, actionRightButton: () { Navigator.of(context).pop(true); }, actionLeftButton: () { Navigator.of(context).pop(); }, ); }, ); if (shouldCopy == true) { await Clipboard.setData(ClipboardData(text: content)); await showBar( navigatorKey.currentContext!, S.of(navigatorKey.currentContext!).copied_to_clipboard, ); } } _hasError = false; } static bool _flutterErrorIgnore(FlutterErrorDetails errorDetails) { // most probably a flutter context error so just ignore it if there is no stack we can debug with return errorDetails.exception.toString().contains("Null check operator used on a null value") && errorDetails.stack == null; } }