| 1 | import 'dart:io'; |
| 2 | |
| 3 | import 'package:cake_wallet/di.dart'; |
| 4 | import 'package:cake_wallet/entities/preferences_key.dart'; |
| 5 | import 'package:cake_wallet/generated/i18n.dart'; |
| 6 | import 'package:cake_wallet/main.dart'; |
| 7 | import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; |
| 8 | import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; |
| 9 | import 'package:cake_wallet/store/app_store.dart'; |
| 10 | import 'package:cake_wallet/utils/package_info.dart'; |
| 11 | import 'package:cake_wallet/utils/show_bar.dart'; |
| 12 | import 'package:cake_wallet/utils/show_pop_up.dart'; |
| 13 | import 'package:cw_core/root_dir.dart'; |
| 14 | import 'package:cw_core/utils/print_verbose.dart'; |
| 15 | import 'package:device_info_plus/device_info_plus.dart'; |
| 16 | import 'package:flutter/foundation.dart'; |
| 17 | import 'package:flutter/material.dart'; |
| 18 | import 'package:flutter/services.dart'; |
| 19 | import 'package:flutter_mailer/flutter_mailer.dart'; |
| 20 | import 'package:shared_preferences/shared_preferences.dart'; |
| 21 | |
| 22 | class ExceptionHandler { |
| 23 | static bool _hasError = false; |
| 24 | static const _coolDownDurationInDays = |
| 25 | bool.fromEnvironment('hasDevOptions', defaultValue: kDebugMode || kProfileMode) ? 0 : 7; |
| 26 | static File? _file; |
| 27 | |
| 28 | static Future<void> _saveException(String? error, StackTrace? stackTrace, |
| 29 | {String? library}) async { |
| 30 | final appDocDir = await getAppDir(); |
| 31 | |
| 32 | if (_file == null) { |
| 33 | _file = File('${appDocDir.path}/error.txt'); |
| 34 | } |
| 35 | |
| 36 | String? walletType; |
| 37 | CustomTrace? programInfo; |
| 38 | |
| 39 | try { |
| 40 | walletType = getIt.get<AppStore>().wallet?.type.name; |
| 41 | |
| 42 | programInfo = CustomTrace(stackTrace ?? StackTrace.current); |
| 43 | } catch (_) {} |
| 44 | |
| 45 | final exception = { |
| 46 | "${DateTime.now()}": { |
| 47 | "Error": "$error\n\n", |
| 48 | "WalletType": "$walletType\n\n", |
| 49 | "VerboseLog": |
| 50 | "${programInfo?.fileName}#${programInfo?.lineNumber}:${programInfo?.columnNumber} ${programInfo?.callerFunctionName}\n\n", |
| 51 | "Library": "$library\n\n", |
| 52 | "StackTrace": stackTrace.toString(), |
| 53 | } |
| 54 | }; |
| 55 | |
| 56 | const String separator = '''\n\n========================================================== |
| 57 | ==========================================================\n\n'''; |
| 58 | |
| 59 | /// don't save existing errors |
| 60 | if (_file!.existsSync()) { |
| 61 | final String fileContent = await _file!.readAsString(); |
| 62 | if (fileContent.contains("${exception.values.first}")) { |
| 63 | return; |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | _file!.writeAsStringSync( |
| 68 | "$exception $separator", |
| 69 | mode: FileMode.append, |
| 70 | ); |
| 71 | } |
| 72 | |
| 73 | static void _sendExceptionFile() async { |
| 74 | try { |
| 75 | if (_file == null) { |
| 76 | final appDocDir = await getAppDir(); |
| 77 | |
| 78 | _file = File('${appDocDir.path}/error.txt'); |
| 79 | } |
| 80 | |
| 81 | await _addDeviceInfo(_file!); |
| 82 | |
| 83 | // Check if a mail client is available |
| 84 | final bool canSend = await FlutterMailer.canSendMail(); |
| 85 | |
| 86 | if (Platform.isIOS && !canSend) { |
| 87 | printV('Mail app is not available'); |
| 88 | return; |
| 89 | } |
| 90 | |
| 91 | final MailOptions mailOptions = MailOptions( |
| 92 | subject: 'Mobile App Issue', |
| 93 | recipients: ['support@cakewallet.com'], |
| 94 | attachments: [_file!.path], |
| 95 | ); |
| 96 | |
| 97 | final result = await FlutterMailer.send(mailOptions); |
| 98 | |
| 99 | // Clear file content if the error was sent or saved. |
| 100 | // On android we can't know if it was sent or saved |
| 101 | if (result.name == MailerResponse.sent.name || |
| 102 | result.name == MailerResponse.saved.name || |
| 103 | result.name == MailerResponse.android.name) { |
| 104 | _file!.writeAsString("", mode: FileMode.write); |
| 105 | } |
| 106 | } catch (e, s) { |
| 107 | _saveException(e.toString(), s); |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | static Future<void> resetLastPopupDate() async { |
| 112 | final sharedPrefs = await SharedPreferences.getInstance(); |
| 113 | await sharedPrefs.setString(PreferencesKey.lastPopupDate, DateTime(1971).toString()); |
| 114 | } |
| 115 | |
| 116 | static Future<void> onError(FlutterErrorDetails errorDetails) async { |
| 117 | if (await onLedgerError(errorDetails)) return; |
| 118 | |
| 119 | if (kDebugMode || kProfileMode) { |
| 120 | if (_ignoreError(errorDetails.exception.toString()) || |
| 121 | _ignoreError(errorDetails.stack.toString())) { |
| 122 | printV("(BELOW ERROR IS IGNORED AND WILL NOT TRIGGER POPUP IN PROD)"); |
| 123 | } |
| 124 | FlutterError.presentError(errorDetails); |
| 125 | errorDetails.toString().split("\n").forEach(printV); |
| 126 | return; |
| 127 | } |
| 128 | |
| 129 | if (_ignoreError(errorDetails.exception.toString()) || |
| 130 | _ignoreError(errorDetails.stack.toString()) || |
| 131 | _flutterErrorIgnore(errorDetails)) { |
| 132 | return; |
| 133 | } |
| 134 | |
| 135 | _saveException( |
| 136 | errorDetails.exceptionAsString(), |
| 137 | errorDetails.stack, |
| 138 | library: errorDetails.library, |
| 139 | ); |
| 140 | |
| 141 | if (errorDetails.silent) { |
| 142 | return; |
| 143 | } |
| 144 | |
| 145 | final sharedPrefs = await SharedPreferences.getInstance(); |
| 146 | |
| 147 | final lastPopupDate = |
| 148 | DateTime.tryParse(sharedPrefs.getString(PreferencesKey.lastPopupDate) ?? '') ?? |
| 149 | DateTime.now().subtract(Duration(days: _coolDownDurationInDays + 1)); |
| 150 | |
| 151 | final durationSinceLastReport = DateTime.now().difference(lastPopupDate).inDays; |
| 152 | |
| 153 | if (_hasError || durationSinceLastReport < _coolDownDurationInDays) { |
| 154 | return; |
| 155 | } |
| 156 | _hasError = true; |
| 157 | |
| 158 | await sharedPrefs.setString(PreferencesKey.lastPopupDate, DateTime.now().toString()); |
| 159 | |
| 160 | // Instead of using WidgetsBinding.instance.addPostFrameCallback we |
| 161 | // await Future.delayed(Duration.zero), which does essentially the same ( |
| 162 | // but doesn't wait for actual frame to be rendered), but it allows us to |
| 163 | // properly await the execution - which is what we want, without awaiting |
| 164 | // other code may call functions like Navigator.pop(), and close the alert |
| 165 | // instead of the intended UI. |
| 166 | // WidgetsBinding.instance.addPostFrameCallback( |
| 167 | // (timeStamp) async { |
| 168 | await Future.delayed(Duration.zero); |
| 169 | if (navigatorKey.currentContext != null) { |
| 170 | await showPopUp<void>( |
| 171 | context: navigatorKey.currentContext!, |
| 172 | builder: (context) { |
| 173 | return AlertWithTwoActions( |
| 174 | isDividerExist: true, |
| 175 | alertTitle: S.of(context).error, |
| 176 | alertContent: S.of(context).error_dialog_content, |
| 177 | rightButtonText: S.of(context).send, |
| 178 | leftButtonText: S.of(context).do_not_send, |
| 179 | actionRightButton: () { |
| 180 | Navigator.of(context).pop(); |
| 181 | _sendExceptionFile(); |
| 182 | }, |
| 183 | actionLeftButton: () { |
| 184 | Navigator.of(context).pop(); |
| 185 | }, |
| 186 | ); |
| 187 | }, |
| 188 | ); |
| 189 | } |
| 190 | |
| 191 | _hasError = false; |
| 192 | } |
| 193 | |
| 194 | static const List<String> _ledgerErrors = [ |
| 195 | 'Wrong Device Status', |
| 196 | 'PlatformException(133, Failed to write: (Unknown Error: 133), null, null)', |
| 197 | 'PlatformException(IllegalArgument, Unknown deviceId:', |
| 198 | 'ServiceNotSupportedException(ConnectionType.ble, Required service not supported. Write characteristic: false, Notify characteristic: false)', |
| 199 | 'Make sure no other program is communicating with the Ledger', |
| 200 | 'Exception: 6e01', // Wrong App |
| 201 | 'Exception: 6d02', |
| 202 | 'Exception: 6511', |
| 203 | 'Exception: 6e00', |
| 204 | 'Exception: 6985', |
| 205 | 'Exception: 5515', |
| 206 | ]; |
| 207 | |
| 208 | static bool isLedgerError(Object exception) => |
| 209 | _ledgerErrors.any((element) => exception.toString().contains(element)); |
| 210 | |
| 211 | static Future<bool> onLedgerError(FlutterErrorDetails errorDetails) async { |
| 212 | if (!isLedgerError(errorDetails.exception)) return false; |
| 213 | |
| 214 | String? interpretErrorCode(String errorCode) { |
| 215 | if (errorCode.contains("6985")) { |
| 216 | return S.current.ledger_error_tx_rejected_by_user; |
| 217 | } else if (errorCode.contains("5515")) { |
| 218 | return S.current.ledger_error_device_locked; |
| 219 | } else if (["6e01", "6d02", "6511", "6e00"].any((e) => errorCode.contains(e))) { |
| 220 | return S.current.ledger_error_wrong_app; |
| 221 | } |
| 222 | return null; |
| 223 | } |
| 224 | |
| 225 | printV(errorDetails.exception); |
| 226 | |
| 227 | if (navigatorKey.currentContext != null) { |
| 228 | await showPopUp<void>( |
| 229 | context: navigatorKey.currentContext!, |
| 230 | builder: (context) => AlertWithOneAction( |
| 231 | alertTitle: "Ledger Error", |
| 232 | alertContent: interpretErrorCode(errorDetails.exception.toString()) ?? |
| 233 | S.of(context).ledger_connection_error, |
| 234 | buttonText: S.of(context).close, |
| 235 | buttonAction: () => Navigator.of(context).pop(), |
| 236 | ), |
| 237 | ); |
| 238 | } |
| 239 | |
| 240 | _hasError = false; |
| 241 | return true; |
| 242 | } |
| 243 | |
| 244 | /// Ignore User related errors or system errors |
| 245 | static bool _ignoreError(String error) => |
| 246 | _ignoredErrors.any((element) => error.contains(element)); |
| 247 | |
| 248 | static const List<String> _ignoredErrors = const [ |
| 249 | "Bad file descriptor", |
| 250 | "No space left on device", |
| 251 | "OS Error: Broken pipe", |
| 252 | "Can't assign requested address", |
| 253 | "OS Error: Socket is not connected", |
| 254 | "Operation timed out", |
| 255 | "No route to host", |
| 256 | "Software caused connection abort", |
| 257 | "Connection reset by peer", |
| 258 | "Connection timed out", |
| 259 | "Connection reset by peer", |
| 260 | "Connection closed before full header was received", |
| 261 | "Connection closed while receiving data", |
| 262 | "Connection terminated during handshake", |
| 263 | "OS Error: Connection refused, errno = 61", |
| 264 | "PERMISSION_NOT_GRANTED", |
| 265 | "OS Error: Permission denied", |
| 266 | "Failed host lookup:", |
| 267 | "CERTIFICATE_VERIFY_FAILED", |
| 268 | "Handshake error in client", |
| 269 | "Error while launching http", |
| 270 | "OS Error: Network is unreachable", |
| 271 | "ClientException: Write failed, uri=http", |
| 272 | "Corrupted wallets seeds", |
| 273 | "bad_alloc", |
| 274 | "does not correspond", |
| 275 | "basic_string", |
| 276 | "input_stream", |
| 277 | "input stream error", |
| 278 | "invalid signature", |
| 279 | "invalid password", |
| 280 | "NetworkImage._loadAsync", |
| 281 | "Invalid image data", |
| 282 | "SSLV3_ALERT_BAD_RECORD_MAC", |
| 283 | "PlatformException(already_active, File picker is already active", |
| 284 | // SVG-related errors |
| 285 | "SvgParser", |
| 286 | "SVG parsing error", |
| 287 | "Invalid SVG", |
| 288 | "SVG format error", |
| 289 | "SvgPicture", |
| 290 | "Unable to load asset", |
| 291 | // Temporary ignored, More context: Flutter secure storage reads the values as null some times |
| 292 | // probably when the device was locked and then opened on Cake |
| 293 | // this is solved by a restart of the app |
| 294 | // just ignoring until we find a solution to this issue or migrate from flutter secure storage |
| 295 | "core/auth_service.dart:92", |
| 296 | "core/key_service.dart:14", |
| 297 | "Wallet is null", |
| 298 | "Wrong Device Status: 0x5515 (UNKNOWN)", |
| 299 | "Command handling failed. With error: hostUnreachable", |
| 300 | |
| 301 | // Android IME/Gboard occasionally reports a caret offset past the end of |
| 302 | // the text on the platform text-input channel while typing. Non-fatal |
| 303 | // framework<->platform desync; the app keeps working. |
| 304 | "invalid selection start", |
| 305 | "FocusScopeNode was used after being disposed", |
| 306 | "_getDismissibleFlushbar", |
| 307 | "_QueuedFuture.execute (package:universal_ble/src/queue.dart:65)", |
| 308 | "Pending Request Canceled | RequestQueue disposed", |
| 309 | "reown_core/relay_client/websocket/websocket_handler.dart", |
| 310 | "Image upload failed due to loss of GPU access", |
| 311 | "transport error", |
| 312 | "SdkError.sparkError(field0: Operator RPC error: Connection error: status: Unavailable, message: \"dns error\", details: []", |
| 313 | "the timeout of the request was reached", |
| 314 | |
| 315 | "support for coin removed, your seedphrase:", |
| 316 | "Exception: Invalid image data", |
| 317 | ]; |
| 318 | |
| 319 | static Future<void> _addDeviceInfo(File file) async { |
| 320 | final packageInfo = await PackageInfo.fromPlatform(); |
| 321 | final currentVersion = packageInfo.version; |
| 322 | final appName = packageInfo.appName; |
| 323 | final package = packageInfo.packageName; |
| 324 | |
| 325 | final deviceInfoPlugin = DeviceInfoPlugin(); |
| 326 | Map<String, dynamic> deviceInfo = {}; |
| 327 | |
| 328 | if (Platform.isAndroid) { |
| 329 | deviceInfo = _readAndroidBuildData(await deviceInfoPlugin.androidInfo); |
| 330 | deviceInfo["Platform"] = "Android"; |
| 331 | } else if (Platform.isIOS) { |
| 332 | deviceInfo = _readIosDeviceInfo(await deviceInfoPlugin.iosInfo); |
| 333 | deviceInfo["Platform"] = "iOS"; |
| 334 | } else if (Platform.isLinux) { |
| 335 | deviceInfo = _readLinuxDeviceInfo(await deviceInfoPlugin.linuxInfo); |
| 336 | deviceInfo["Platform"] = "Linux"; |
| 337 | } else if (Platform.isMacOS) { |
| 338 | deviceInfo = _readMacOsDeviceInfo(await deviceInfoPlugin.macOsInfo); |
| 339 | deviceInfo["Platform"] = "MacOS"; |
| 340 | } else if (Platform.isWindows) { |
| 341 | deviceInfo = _readWindowsDeviceInfo(await deviceInfoPlugin.windowsInfo); |
| 342 | deviceInfo["Platform"] = "Windows"; |
| 343 | } |
| 344 | |
| 345 | await file.writeAsString( |
| 346 | "App Version: $currentVersion\nApp Name: $appName\nPackage: $package\n\nDevice Info $deviceInfo\n\n", |
| 347 | mode: FileMode.append, |
| 348 | ); |
| 349 | } |
| 350 | |
| 351 | static Map<String, dynamic> _readAndroidBuildData(AndroidDeviceInfo build) { |
| 352 | return <String, dynamic>{ |
| 353 | 'brand': build.brand, |
| 354 | 'device': build.device, |
| 355 | 'manufacturer': build.manufacturer, |
| 356 | 'model': build.model, |
| 357 | 'product': build.product, |
| 358 | }; |
| 359 | } |
| 360 | |
| 361 | static Map<String, dynamic> _readIosDeviceInfo(IosDeviceInfo data) { |
| 362 | return <String, dynamic>{ |
| 363 | 'systemName': data.systemName, |
| 364 | 'systemVersion': data.systemVersion, |
| 365 | 'model': data.model, |
| 366 | 'localizedModel': data.localizedModel, |
| 367 | 'isPhysicalDevice': data.isPhysicalDevice, |
| 368 | }; |
| 369 | } |
| 370 | |
| 371 | static Map<String, dynamic> _readLinuxDeviceInfo(LinuxDeviceInfo data) { |
| 372 | return <String, dynamic>{ |
| 373 | 'name': data.name, |
| 374 | 'version': data.version, |
| 375 | 'versionCodename': data.versionCodename, |
| 376 | 'versionId': data.versionId, |
| 377 | 'prettyName': data.prettyName, |
| 378 | 'buildId': data.buildId, |
| 379 | 'variant': data.variant, |
| 380 | 'variantId': data.variantId, |
| 381 | }; |
| 382 | } |
| 383 | |
| 384 | static Map<String, dynamic> _readMacOsDeviceInfo(MacOsDeviceInfo data) { |
| 385 | return <String, dynamic>{ |
| 386 | 'arch': data.arch, |
| 387 | 'model': data.model, |
| 388 | 'kernelVersion': data.kernelVersion, |
| 389 | 'osRelease': data.osRelease, |
| 390 | }; |
| 391 | } |
| 392 | |
| 393 | static Map<String, dynamic> _readWindowsDeviceInfo(WindowsDeviceInfo data) { |
| 394 | return <String, dynamic>{ |
| 395 | 'majorVersion': data.majorVersion, |
| 396 | 'minorVersion': data.minorVersion, |
| 397 | 'buildNumber': data.buildNumber, |
| 398 | 'productType': data.productType, |
| 399 | 'productName': data.productName, |
| 400 | }; |
| 401 | } |
| 402 | |
| 403 | static Future<void> showError(String error, {int? delayInSeconds}) async { |
| 404 | if (_hasError) { |
| 405 | return; |
| 406 | } |
| 407 | _hasError = true; |
| 408 | if (delayInSeconds != null) { |
| 409 | Future.delayed(Duration(seconds: delayInSeconds), () => _showCopyPopup(error)); |
| 410 | return; |
| 411 | } |
| 412 | |
| 413 | await Future.delayed(Duration.zero); |
| 414 | await _showCopyPopup(error); |
| 415 | } |
| 416 | |
| 417 | static Future<void> _showCopyPopup(String content) async { |
| 418 | if (navigatorKey.currentContext != null) { |
| 419 | final shouldCopy = await showPopUp<bool?>( |
| 420 | context: navigatorKey.currentContext!, |
| 421 | builder: (context) { |
| 422 | return AlertWithTwoActions( |
| 423 | isDividerExist: true, |
| 424 | alertTitle: S.of(context).error, |
| 425 | alertContent: content, |
| 426 | rightButtonText: S.of(context).copy, |
| 427 | leftButtonText: S.of(context).close, |
| 428 | actionRightButton: () { |
| 429 | Navigator.of(context).pop(true); |
| 430 | }, |
| 431 | actionLeftButton: () { |
| 432 | Navigator.of(context).pop(); |
| 433 | }, |
| 434 | ); |
| 435 | }, |
| 436 | ); |
| 437 | |
| 438 | if (shouldCopy == true) { |
| 439 | await Clipboard.setData(ClipboardData(text: content)); |
| 440 | await showBar<void>( |
| 441 | navigatorKey.currentContext!, |
| 442 | S.of(navigatorKey.currentContext!).copied_to_clipboard, |
| 443 | ); |
| 444 | } |
| 445 | } |
| 446 | |
| 447 | _hasError = false; |
| 448 | } |
| 449 | |
| 450 | static bool _flutterErrorIgnore(FlutterErrorDetails errorDetails) { |
| 451 | if (errorDetails.exception.toString().contains("Null check operator used on a null value")) { |
| 452 | // Most probably a flutter context error so just ignore it if there is no |
| 453 | // stack we can debug with. |
| 454 | if (errorDetails.stack == null) { |
| 455 | return true; |
| 456 | } |
| 457 | |
| 458 | final stack = errorDetails.stack.toString(); |
| 459 | if (stack.contains("handleFocusHighlightModeChange") || |
| 460 | stack.contains("_HighlightModeManager")) { |
| 461 | return true; |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | if (errorDetails.exception.toString().contains("Cannot add event after closing")) { |
| 466 | // grpc-dart teardown race (e.g. the MWEB channel on litecoin): a buffered outgoing |
| 467 | // frame is delivered to the http2 stream's sink after the call/channel was |
| 468 | // terminated. The message alone is too generic to ignore, so require the exact |
| 469 | // shape of grpc's forwarding chain: .map().map().handleError().listen(sink.add). |
| 470 | final stack = errorDetails.stack.toString(); |
| 471 | if (stack.contains("_StreamSinkWrapper.add") && |
| 472 | stack.contains("_MapStream._handleData") && |
| 473 | stack.contains("_HandleErrorStream._handleData")) { |
| 474 | return true; |
| 475 | } |
| 476 | } |
| 477 | |
| 478 | return false; |
| 479 | } |
| 480 | } |