mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-17 05:41:29 +02:00
An error report that cannot be tied to a binary tells us a bug exists and nothing about whether we already fixed it. Of the ten reports received since May, five carry the Wownero addresses crash that shipped fixed in 1.0.2, and we could only tell by reading timestamps buried in the traces themselves. Two gaps, both closed here. The body carried "App Version: 1.0.0" without the build number, and 1.0.0 shipped as Play builds 9 and 10 which encrypted under different keys, so the version alone cannot identify the binary that wrote a wallet we are being asked to heal. And the body is the part of this flow we do not control. It reaches us only if the user pastes what we put on their clipboard. On 2026-09-17 somebody typed a sentence into the trace box instead, and the report arrived with no version, no platform and no stack at all. So the app now also puts its version and platform in the query string of the link it opens, where the form reads it rather than the user. Someone who reaches the page without the app simply reports nothing, which is honest and stays empty rather than guessing.
481 lines
17 KiB
Dart
481 lines
17 KiB
Dart
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<void> _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<AppStore>().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));
|
|
|
|
// Carry the build in the URL as well as in the pasted body. A report is
|
|
// only useful if we can tell which binary produced it, and the body is
|
|
// the one part of this flow we do not control: on 2026-09-17 somebody
|
|
// typed a sentence into the trace box instead of pasting, and the report
|
|
// arrived with no version, no platform and no stack. The query string
|
|
// survives that, because the form reads it rather than the user.
|
|
final packageInfo = await PackageInfo.fromPlatform();
|
|
final uri = Uri.parse(_reportUrl).replace(queryParameters: {
|
|
'v': '${packageInfo.version}+${packageInfo.buildNumber}',
|
|
'p': _platformName(),
|
|
});
|
|
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<void> resetLastPopupDate() async {
|
|
final sharedPrefs = await SharedPreferences.getInstance();
|
|
await sharedPrefs.setString(PreferencesKey.lastPopupDate, DateTime(1971).toString());
|
|
}
|
|
|
|
static Future<void> 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<void>(
|
|
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<String> _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<bool> 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<void>(
|
|
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<String> _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 String _platformName() {
|
|
if (Platform.isAndroid) return 'Android';
|
|
if (Platform.isIOS) return 'iOS';
|
|
if (Platform.isLinux) return 'Linux';
|
|
if (Platform.isMacOS) return 'macOS';
|
|
if (Platform.isWindows) return 'Windows';
|
|
return 'unknown';
|
|
}
|
|
|
|
static Future<void> _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<String, dynamic> 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";
|
|
}
|
|
|
|
// The build number matters as much as the version: 1.0.0 shipped as Play
|
|
// builds 9 and 10, which encrypted under different keys, so "1.0.0" alone
|
|
// cannot tell us which binary wrote a wallet we are being asked to heal.
|
|
await file.writeAsString(
|
|
"App Version: $currentVersion+${packageInfo.buildNumber}\n"
|
|
"App Name: $appName\nPackage: $package\n\nDevice Info $deviceInfo\n\n",
|
|
mode: FileMode.append,
|
|
);
|
|
}
|
|
|
|
static Map<String, dynamic> _readAndroidBuildData(AndroidDeviceInfo build) {
|
|
return <String, dynamic>{
|
|
'brand': build.brand,
|
|
'device': build.device,
|
|
'manufacturer': build.manufacturer,
|
|
'model': build.model,
|
|
'product': build.product,
|
|
};
|
|
}
|
|
|
|
static Map<String, dynamic> _readIosDeviceInfo(IosDeviceInfo data) {
|
|
return <String, dynamic>{
|
|
'systemName': data.systemName,
|
|
'systemVersion': data.systemVersion,
|
|
'model': data.model,
|
|
'localizedModel': data.localizedModel,
|
|
'isPhysicalDevice': data.isPhysicalDevice,
|
|
};
|
|
}
|
|
|
|
static Map<String, dynamic> _readLinuxDeviceInfo(LinuxDeviceInfo data) {
|
|
return <String, dynamic>{
|
|
'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<String, dynamic> _readMacOsDeviceInfo(MacOsDeviceInfo data) {
|
|
return <String, dynamic>{
|
|
'arch': data.arch,
|
|
'model': data.model,
|
|
'kernelVersion': data.kernelVersion,
|
|
'osRelease': data.osRelease,
|
|
};
|
|
}
|
|
|
|
static Map<String, dynamic> _readWindowsDeviceInfo(WindowsDeviceInfo data) {
|
|
return <String, dynamic>{
|
|
'majorVersion': data.majorVersion,
|
|
'minorVersion': data.minorVersion,
|
|
'buildNumber': data.buildNumber,
|
|
'productType': data.productType,
|
|
'productName': data.productName,
|
|
};
|
|
}
|
|
|
|
static Future<void> 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<void> _showCopyPopup(String content) async {
|
|
if (navigatorKey.currentContext != null) {
|
|
final shouldCopy = await showPopUp<bool?>(
|
|
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<void>(
|
|
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;
|
|
}
|
|
}
|