only-bible-app v3.7.2+24
git clone https://git.pyrossh.dev/only-bible-app
The only bible app you will ever need. No ads. No in-app purchases. No distractions.
lib/error_reporting.dart
| 0228ae8 | 1 | import "dart:async"; |
| 0228ae8 | 2 | import "dart:convert"; |
| 0228ae8 | 3 | |
| 0228ae8 | 4 | import "package:flutter/foundation.dart"; |
| 0228ae8 | 5 | import "package:flutter/material.dart"; |
| 0228ae8 | 6 | import "package:flutter/scheduler.dart"; |
| 0228ae8 | 7 | import "package:http/http.dart" as http; |
| 0228ae8 | 8 | |
| 0228ae8 | 9 | /// Sends (or otherwise records) a single error report. Implementations |
| 0228ae8 | 10 | /// should be best-effort — a failed report must never throw back into the |
| 0228ae8 | 11 | /// error handler that triggered it. |
| 0228ae8 | 12 | typedef ErrorReportSender = |
| 0228ae8 | 13 | Future<void> Function(String message, StackTrace? stack); |
| 0228ae8 | 14 | |
| 0228ae8 | 15 | /// Global crash/error reporting: wires [FlutterError.onError] (framework |
| 0228ae8 | 16 | /// errors) and [PlatformDispatcher.onError] (uncaught async/platform |
| 0228ae8 | 17 | /// errors), shows a user-consent dialog before reporting in release builds, |
| 0228ae8 | 18 | /// and just logs to the console in debug builds. |
| 0228ae8 | 19 | class ErrorReporting { |
| 0228ae8 | 20 | ErrorReporting._(); |
| 0228ae8 | 21 | |
| 0228ae8 | 22 | static GlobalKey<NavigatorState>? _navigatorKey; |
| 0228ae8 | 23 | static ErrorReportSender? _onReport; |
| 0228ae8 | 24 | static Set<String> _ignoredSubstrings = const {}; |
| 0228ae8 | 25 | |
| 0228ae8 | 26 | /// Call once at app startup, after `runApp`'s `navigatorKey` exists. |
| 0228ae8 | 27 | static void install({ |
| 0228ae8 | 28 | required GlobalKey<NavigatorState> navigatorKey, |
| 0228ae8 | 29 | required ErrorReportSender onReport, |
| 0228ae8 | 30 | Set<String> ignoredSubstrings = const {}, |
| 0228ae8 | 31 | }) { |
| 0228ae8 | 32 | _navigatorKey = navigatorKey; |
| 0228ae8 | 33 | _onReport = onReport; |
| 0228ae8 | 34 | _ignoredSubstrings = ignoredSubstrings; |
| 0228ae8 | 35 | |
| 0228ae8 | 36 | FlutterError.onError = (errorDetails) { |
| 0228ae8 | 37 | FlutterError.presentError(errorDetails); |
| 0228ae8 | 38 | _handle(errorDetails.exception.toString(), errorDetails.stack); |
| 0228ae8 | 39 | }; |
| 0228ae8 | 40 | |
| 0228ae8 | 41 | PlatformDispatcher.instance.onError = (error, stack) { |
| 0228ae8 | 42 | _handle(error.toString(), stack); |
| 0228ae8 | 43 | return true; |
| 0228ae8 | 44 | }; |
| 0228ae8 | 45 | } |
| 0228ae8 | 46 | |
| 0228ae8 | 47 | /// Manually reports [message] through the same sender configured via |
| 0228ae8 | 48 | /// [install], bypassing the consent dialog — for call sites that already |
| 0228ae8 | 49 | /// caught and handled a specific error and just want it recorded (e.g. |
| 0228ae8 | 50 | /// only-bible-app's audio-playback failure handler). |
| 0228ae8 | 51 | static Future<void> report(String message, StackTrace? stack) async { |
| 0228ae8 | 52 | if (kDebugMode) { |
| 0228ae8 | 53 | debugPrint("Error: $message"); |
| 0228ae8 | 54 | debugPrint("$stack"); |
| 0228ae8 | 55 | return; |
| 0228ae8 | 56 | } |
| 0228ae8 | 57 | await _onReport?.call(message, stack); |
| 0228ae8 | 58 | } |
| 0228ae8 | 59 | |
| 0228ae8 | 60 | static void _handle(String message, StackTrace? stack) { |
| 0228ae8 | 61 | if (kDebugMode) { |
| 0228ae8 | 62 | debugPrint("Error: $message"); |
| 0228ae8 | 63 | debugPrint("$stack"); |
| 0228ae8 | 64 | return; |
| 0228ae8 | 65 | } |
| 0228ae8 | 66 | if (_ignoredSubstrings.any(message.contains)) { |
| 0228ae8 | 67 | // Known framework noise: still recorded for visibility, just not |
| 0228ae8 | 68 | // worth interrupting the user with a dialog they can't act on. |
| 0228ae8 | 69 | unawaited(_onReport?.call(message, stack)); |
| 0228ae8 | 70 | return; |
| 0228ae8 | 71 | } |
| 0228ae8 | 72 | SchedulerBinding.instance.addPostFrameCallback((_) { |
| 0228ae8 | 73 | final context = _navigatorKey?.currentContext; |
| 0228ae8 | 74 | if (context == null || !context.mounted) return; |
| 0228ae8 | 75 | _showReportDialog(context, message, stack); |
| 0228ae8 | 76 | }); |
| 0228ae8 | 77 | } |
| 0228ae8 | 78 | |
| 0228ae8 | 79 | static void _showReportDialog( |
| 0228ae8 | 80 | BuildContext context, |
| 0228ae8 | 81 | String message, |
| 0228ae8 | 82 | StackTrace? stack, |
| 0228ae8 | 83 | ) { |
| 0228ae8 | 84 | showDialog<void>( |
| 0228ae8 | 85 | context: context, |
| 0228ae8 | 86 | barrierColor: Colors.black54, |
| 0228ae8 | 87 | builder: (dialogContext) => AlertDialog( |
| 0228ae8 | 88 | title: const Text("Alert"), |
| 0228ae8 | 89 | content: const Text( |
| 0228ae8 | 90 | "An error has occurred. Do you want to report this error to us?", |
| 0228ae8 | 91 | ), |
| 0228ae8 | 92 | actionsAlignment: MainAxisAlignment.end, |
| 0228ae8 | 93 | actionsOverflowButtonSpacing: 8.0, |
| 0228ae8 | 94 | actions: [ |
| 0228ae8 | 95 | TextButton( |
| 0228ae8 | 96 | onPressed: () { |
| 0228ae8 | 97 | Navigator.of(dialogContext).pop(); |
| 0228ae8 | 98 | unawaited(_onReport?.call(message, stack)); |
| 0228ae8 | 99 | }, |
| 0228ae8 | 100 | child: const Text("Yes"), |
| 0228ae8 | 101 | ), |
| 0228ae8 | 102 | TextButton( |
| 0228ae8 | 103 | onPressed: () => Navigator.of(dialogContext).pop(), |
| 0228ae8 | 104 | child: const Text("No"), |
| 0228ae8 | 105 | ), |
| 0228ae8 | 106 | ], |
| 0228ae8 | 107 | ), |
| 0228ae8 | 108 | ); |
| 0228ae8 | 109 | } |
| 0228ae8 | 110 | } |
| 0228ae8 | 111 | |
| 0228ae8 | 112 | /// Builds an [ErrorReportSender] that POSTs to the shared `error-reporter` |
| 0228ae8 | 113 | /// Cloudflare Worker (see workers/error-reporter/), which emails the report |
| 0228ae8 | 114 | /// via Cloudflare Email Sending. [sharedSecret], if provided, is sent as |
| 0228ae8 | 115 | /// `X-Report-Secret` — this is NOT a real security boundary (it's embedded |
| 0228ae8 | 116 | /// client-side, same as any app secret), it only filters out generic bots |
| 0228ae8 | 117 | /// blindly probing the endpoint. Real abuse resistance is the Worker's own |
| 0228ae8 | 118 | /// rate limiter. |
| 0228ae8 | 119 | ErrorReportSender httpErrorReportSender({ |
| 0228ae8 | 120 | required Uri endpoint, |
| 0228ae8 | 121 | required String appName, |
| 0228ae8 | 122 | String? sharedSecret, |
| 0228ae8 | 123 | String? appVersion, |
| 0228ae8 | 124 | String? buildNumber, |
| 0228ae8 | 125 | }) { |
| 0228ae8 | 126 | return (message, stack) async { |
| 0228ae8 | 127 | try { |
| 0228ae8 | 128 | await http.post( |
| 0228ae8 | 129 | endpoint, |
| 0228ae8 | 130 | headers: { |
| 0228ae8 | 131 | "Content-Type": "application/json", |
| 0228ae8 | 132 | if (sharedSecret != null) "X-Report-Secret": sharedSecret, |
| 0228ae8 | 133 | }, |
| 0228ae8 | 134 | body: jsonEncode({ |
| 0228ae8 | 135 | "app": appName, |
| 0228ae8 | 136 | "message": message, |
| 0228ae8 | 137 | "stack": stack?.toString(), |
| 0228ae8 | 138 | "platform": defaultTargetPlatform.name, |
| 0228ae8 | 139 | "appVersion": appVersion, |
| 0228ae8 | 140 | "buildNumber": buildNumber, |
| 0228ae8 | 141 | }), |
| 0228ae8 | 142 | ); |
| 0228ae8 | 143 | } catch (_) { |
| 0228ae8 | 144 | // Best-effort — a failed report must not cascade into another error. |
| 0228ae8 | 145 | } |
| 0228ae8 | 146 | }; |
| 0228ae8 | 147 | } |