dev
dart 480 lines 16.3 KB
Raw
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 }