From 38dcc3981f81725ceaa5d94ecf62ad306c1f9a37 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Fri, 6 Feb 2026 13:20:47 -0300 Subject: [PATCH] Improve error reporting --- lib/services/error_reporting_service.dart | 179 ----------- lib/services/notification_service.dart | 291 ++++++++---------- .../android_package_manager.dart | 10 +- .../package_manager/package_manager.dart | 14 +- lib/widgets/install_button.dart | 108 +++---- spec/guidelines/INVARIANTS.md | 5 + 6 files changed, 186 insertions(+), 421 deletions(-) delete mode 100644 lib/services/error_reporting_service.dart diff --git a/lib/services/error_reporting_service.dart b/lib/services/error_reporting_service.dart deleted file mode 100644 index 40ca23f..0000000 --- a/lib/services/error_reporting_service.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:models/models.dart'; -import 'package:zapstore/utils/extensions.dart'; - -/// Service for reporting errors via NIP-44 encrypted DMs to Zapstore team. -/// -/// Generates an ephemeral signer per report so errors can be sent even when -/// the user is not signed in. -class ErrorReportingService { - ErrorReportingService(this.ref); - - final Ref ref; - - /// Track reported errors to avoid spam (error hash -> timestamp) - final Map _reportedErrors = {}; - - /// Rate limit: one report per error type per session - static const _rateLimitDuration = Duration(minutes: 30); - - /// Maximum reports per session to prevent abuse - static const _maxReportsPerSession = 10; - int _sessionReportCount = 0; - - /// Report an error to the Zapstore team via encrypted DM. - /// - /// Rate-limited to prevent spam. Only sends one report per unique - /// error type per session. - Future reportError(Object exception, StackTrace? stackTrace) async { - // Don't report in debug mode - if (kDebugMode) { - return; - } - - // Check session limit - if (_sessionReportCount >= _maxReportsPerSession) { - return; - } - - // Rate limit by error type - final errorHash = exception.toString().hashCode; - final lastReport = _reportedErrors[errorHash]; - if (lastReport != null && - DateTime.now().difference(lastReport) < _rateLimitDuration) { - return; - } - - try { - // Create ephemeral signer for error reporting - final signer = Bip340PrivateKeySigner(Utils.generateRandomHex64(), ref); - await signer.signIn(setAsActive: false, registerSigner: false); - - // Format error report - final report = _formatErrorReport(exception, stackTrace); - - // Create encrypted DM to Zapstore pubkey (uses NIP-44 by default) - final dm = PartialDirectMessage( - content: report, - receiver: kZapstorePubkey, - ); - - // Sign and publish - final signedDm = await dm.signWith(signer); - await ref.read(storageNotifierProvider.notifier).publish({ - signedDm, - }, source: const RemoteSource(relays: 'social')); - - // Update rate limiting - _reportedErrors[errorHash] = DateTime.now(); - _sessionReportCount++; - } catch (e) { - // Silently fail - we don't want error reporting to cause more errors - } - } - - /// User-initiated error report (e.g., from "Report issue" button). - /// - /// Returns true if the report was sent successfully. - /// Rate-limited to prevent accidental spam from repeated taps. - Future reportUserError({ - required String title, - String? technicalDetails, - }) async { - // Check session limit - if (_sessionReportCount >= _maxReportsPerSession) { - return false; - } - - // Rate limit by error content - final errorHash = '$title\n${technicalDetails ?? ''}'.hashCode; - final lastReport = _reportedErrors[errorHash]; - if (lastReport != null && - DateTime.now().difference(lastReport) < _rateLimitDuration) { - return false; - } - - try { - // Create ephemeral signer for error reporting - final signer = Bip340PrivateKeySigner(Utils.generateRandomHex64(), ref); - await signer.signIn(setAsActive: false, registerSigner: false); - - // Format error report - final report = _formatUserErrorReport(title, technicalDetails); - - // Create encrypted DM to Zapstore pubkey (uses NIP-44 by default) - final dm = PartialDirectMessage( - content: report, - receiver: kZapstorePubkey, - ); - - // Sign and publish - final signedDm = await dm.signWith(signer); - await ref.read(storageNotifierProvider.notifier).publish({ - signedDm, - }, source: const RemoteSource(relays: 'social')); - - // Update rate limiting - _reportedErrors[errorHash] = DateTime.now(); - _sessionReportCount++; - - return true; - } catch (e) { - debugPrint('[ErrorReporting] Failed to send user report: $e'); - return false; - } - } - - String _formatUserErrorReport(String title, String? technicalDetails) { - final buffer = StringBuffer(); - - buffer.writeln('=== ZAPSTORE USER ERROR REPORT ==='); - buffer.writeln('Timestamp: ${DateTime.now().toUtc().toIso8601String()}'); - buffer.writeln('Platform: ${Platform.operatingSystem}'); - buffer.writeln('OS Version: ${Platform.operatingSystemVersion}'); - buffer.writeln(''); - buffer.writeln('Error: $title'); - - if (technicalDetails != null && technicalDetails.isNotEmpty) { - buffer.writeln(''); - buffer.writeln('Technical Details:'); - buffer.writeln(technicalDetails); - } - - return buffer.toString(); - } - - String _formatErrorReport(Object exception, StackTrace? stackTrace) { - final buffer = StringBuffer(); - - buffer.writeln('=== ZAPSTORE ERROR REPORT ==='); - buffer.writeln('Timestamp: ${DateTime.now().toUtc().toIso8601String()}'); - buffer.writeln('Platform: ${Platform.operatingSystem}'); - buffer.writeln('OS Version: ${Platform.operatingSystemVersion}'); - buffer.writeln(''); - buffer.writeln('Exception:'); - buffer.writeln(exception.toString()); - buffer.writeln(''); - - if (stackTrace != null) { - buffer.writeln('Stack Trace:'); - // Limit stack trace length to avoid huge messages - final stackLines = stackTrace.toString().split('\n'); - final limitedStack = stackLines.take(30).join('\n'); - buffer.writeln(limitedStack); - if (stackLines.length > 30) { - buffer.writeln('... (${stackLines.length - 30} more lines)'); - } - } - - return buffer.toString(); - } -} - -/// Provider for the error reporting service -final errorReportingServiceProvider = Provider( - ErrorReportingService.new, -); diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index b8fe166..53e1940 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:zapstore/router.dart'; import 'package:zapstore/theme.dart'; @@ -77,7 +80,7 @@ void _showCustomToast({ overlay.insert(entry); } -class _ToastOverlay extends StatefulWidget { +class _ToastOverlay extends HookWidget { final String title; final String? description; final IconData icon; @@ -94,73 +97,49 @@ class _ToastOverlay extends StatefulWidget { required this.onDismiss, }); - @override - State<_ToastOverlay> createState() => _ToastOverlayState(); -} - -class _ToastOverlayState extends State<_ToastOverlay> - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _fadeAnimation; - late Animation _slideAnimation; - bool _isHovered = false; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: const Duration(milliseconds: 300), - vsync: this, - ); - - _fadeAnimation = CurvedAnimation( - parent: _controller, - curve: Curves.easeOut, - ); - - _slideAnimation = Tween( - begin: const Offset(0, -1), - end: Offset.zero, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); - - _controller.forward(); - _startAutoClose(); - } - - void _startAutoClose() { - // Longer duration when there are actions, or for errors - final hasActions = widget.actions.isNotEmpty; - final duration = hasActions - ? const Duration(seconds: 10) - : widget.type == _ToastType.error - ? const Duration(seconds: 8) - : const Duration(seconds: 6); - - Future.delayed(duration, () { - if (mounted && !_isHovered) { - _dismiss(); - } - }); - } - - void _dismiss() async { - await _controller.reverse(); - widget.onDismiss(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { + final controller = useAnimationController( + duration: const Duration(milliseconds: 300), + ); + final fadeAnimation = useMemoized( + () => CurvedAnimation(parent: controller, curve: Curves.easeOut), + [controller], + ); + final isHovered = useState(false); + + // Forward animation on mount + useEffect(() { + controller.forward(); + return null; + }, const []); + + // Auto-close only for toasts WITHOUT actions + useEffect(() { + if (actions.isNotEmpty) return null; + + final duration = type == _ToastType.error + ? const Duration(seconds: 8) + : const Duration(seconds: 6); + + final timer = Timer(duration, () { + if (!isHovered.value) { + controller.reverse().then((_) => onDismiss()); + } + }); + + return timer.cancel; + }, const []); + + void dismiss() { + controller.reverse().then((_) => onDismiss()); + } + final screenHeight = MediaQuery.of(context).size.height; final maxHeight = screenHeight * 0.7; final topPadding = MediaQuery.of(context).padding.top; - final isError = widget.type == _ToastType.error; + final isError = type == _ToastType.error; // Colors - info: darker light blue, error: red final backgroundColor = isError @@ -181,84 +160,86 @@ class _ToastOverlayState extends State<_ToastOverlay> top: topPadding + 8, left: 12, right: 12, - child: SlideTransition( - position: _slideAnimation, - child: FadeTransition( - opacity: _fadeAnimation, - child: MouseRegion( - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) { - setState(() => _isHovered = false); - _startAutoClose(); + child: FadeTransition( + opacity: fadeAnimation, + child: MouseRegion( + onEnter: (_) => isHovered.value = true, + onExit: (_) { + isHovered.value = false; + // Restart auto-close for toasts without actions + if (actions.isEmpty) { + Future.delayed(const Duration(seconds: 3), () { + if (!isHovered.value) dismiss(); + }); + } + }, + child: GestureDetector( + onTap: actions.isEmpty ? dismiss : null, + onVerticalDragEnd: (details) { + if (details.primaryVelocity != null && + details.primaryVelocity! < -100) { + dismiss(); + } }, - child: GestureDetector( - onTap: _dismiss, - onVerticalDragEnd: (details) { - if (details.primaryVelocity != null && - details.primaryVelocity! < -100) { - _dismiss(); - } - }, - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxHeight), - child: Material( - color: Colors.transparent, - child: Container( - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: borderColor, width: 1), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.4), - blurRadius: 24, - offset: const Offset(0, 8), - spreadRadius: 0, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: Material( + color: Colors.transparent, + child: Container( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor, width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 24, + offset: const Offset(0, 8), + spreadRadius: 0, + ), + BoxShadow( + color: accentColor.withValues(alpha: 0.08), + blurRadius: 40, + offset: const Offset(0, 4), + spreadRadius: 0, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Accent line at top + Container( + height: 3, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + accentColor.withValues(alpha: 0.8), + accentColor.withValues(alpha: 0.4), + accentColor.withValues(alpha: 0.1), + ], + ), + ), ), - BoxShadow( - color: accentColor.withValues(alpha: 0.08), - blurRadius: 40, - offset: const Offset(0, 4), - spreadRadius: 0, + // Content + Flexible( + child: Padding( + padding: const EdgeInsets.all(16), + child: _ToastContent( + icon: icon, + iconBgColor: iconBgColor, + accentColor: accentColor, + title: title, + description: description, + actions: actions, + onDismiss: dismiss, + ), + ), ), ], ), - child: ClipRRect( - borderRadius: BorderRadius.circular(14), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Accent line at top - Container( - height: 3, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - accentColor.withValues(alpha: 0.8), - accentColor.withValues(alpha: 0.4), - accentColor.withValues(alpha: 0.1), - ], - ), - ), - ), - // Content - Flexible( - child: Padding( - padding: const EdgeInsets.all(16), - child: _ToastContent( - icon: widget.icon, - iconBgColor: iconBgColor, - accentColor: accentColor, - title: widget.title, - description: widget.description, - actions: widget.actions, - onDismiss: _dismiss, - ), - ), - ), - ], - ), - ), ), ), ), @@ -270,7 +251,7 @@ class _ToastOverlayState extends State<_ToastOverlay> } } -class _ToastContent extends StatefulWidget { +class _ToastContent extends HookWidget { final IconData icon; final Color iconBgColor; final Color accentColor; @@ -289,18 +270,12 @@ class _ToastContent extends StatefulWidget { required this.onDismiss, }); - @override - State<_ToastContent> createState() => _ToastContentState(); -} - -class _ToastContentState extends State<_ToastContent> { - bool _detailsExpanded = false; - @override Widget build(BuildContext context) { - final hasDescription = - widget.description != null && widget.description!.isNotEmpty; - final hasActions = widget.actions.isNotEmpty; + final detailsExpanded = useState(false); + + final hasDescription = description != null && description!.isNotEmpty; + final hasActions = actions.isNotEmpty; final hasExtraContent = hasDescription || hasActions; // Use Stack to position X at top-right always @@ -320,10 +295,10 @@ class _ToastContentState extends State<_ToastContent> { width: 38, height: 38, decoration: BoxDecoration( - color: widget.iconBgColor, + color: iconBgColor, borderRadius: BorderRadius.circular(10), ), - child: Icon(widget.icon, color: widget.accentColor, size: 20), + child: Icon(icon, color: accentColor, size: 20), ), const SizedBox(width: 14), // Text content @@ -334,7 +309,7 @@ class _ToastContentState extends State<_ToastContent> { children: [ // Title Text( - widget.title, + title, style: const TextStyle( fontFamily: kFontFamily, fontSize: 16, @@ -348,15 +323,13 @@ class _ToastContentState extends State<_ToastContent> { const SizedBox(height: 8), GestureDetector( onTap: () { - setState(() { - _detailsExpanded = !_detailsExpanded; - }); + detailsExpanded.value = !detailsExpanded.value; }, child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( - _detailsExpanded + detailsExpanded.value ? Icons.expand_less : Icons.expand_more, size: 16, @@ -376,7 +349,7 @@ class _ToastContentState extends State<_ToastContent> { ), ), // Expanded description - if (_detailsExpanded) ...[ + if (detailsExpanded.value) ...[ const SizedBox(height: 6), Container( padding: const EdgeInsets.all(8), @@ -385,7 +358,7 @@ class _ToastContentState extends State<_ToastContent> { borderRadius: BorderRadius.circular(6), ), child: SelectableText( - widget.description!, + description!, style: TextStyle( fontFamily: 'monospace', fontSize: 11, @@ -403,15 +376,15 @@ class _ToastContentState extends State<_ToastContent> { Wrap( spacing: 8, runSpacing: 8, - children: widget.actions + children: actions .map( (action) => _ToastActionButton( label: action.$1, onPressed: () { - widget.onDismiss(); + onDismiss(); action.$2(); }, - accentColor: widget.accentColor, + accentColor: accentColor, ), ) .toList(), @@ -428,7 +401,7 @@ class _ToastContentState extends State<_ToastContent> { top: 0, right: 0, child: GestureDetector( - onTap: widget.onDismiss, + onTap: onDismiss, child: Container( width: 28, height: 28, diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index 2f21306..67a76fd 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -375,20 +375,20 @@ final class AndroidPackageManager extends PackageManager { /// Technical details are preserved in the description field. String _getUserFriendlyMessage(FailureType type) { return switch (type) { - FailureType.downloadFailed => 'Download failed. Please try again.', + FailureType.downloadFailed => 'Download failed.', FailureType.hashMismatch => - 'File verification failed. Please try downloading again.', + 'File integrity check failed. Possibly a malicious file, aborting installation.', FailureType.invalidFile => - 'Invalid app file. Please try downloading again.', + 'Invalid app file. The download may be corrupt.', FailureType.certMismatch => 'Update signed by different developer. Uninstall current version to update.', FailureType.permissionDenied => 'Permission required. Please grant install permission and try again.', FailureType.insufficientStorage => - 'Not enough storage space. Please free up space and try again.', + 'Not enough storage space. Free up space to continue and try again.', FailureType.incompatible => 'This app is not compatible with your device.', - FailureType.installFailed => 'Installation failed. Please try again.', + FailureType.installFailed => 'Installation failed.', }; } diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index 54f8713..0f8ee30 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -350,7 +350,7 @@ abstract class PackageManager extends StateNotifier { OperationFailed( target: target, type: FailureType.downloadFailed, - message: 'Download link unavailable. Please try again later.', + message: 'Download link unavailable.', ), ); return false; @@ -626,7 +626,7 @@ abstract class PackageManager extends StateNotifier { OperationFailed( target: op.target, type: FailureType.installFailed, - message: 'Update failed. Please try again.', + message: 'Update failed.', description: errorMessage, filePath: filePath, ), @@ -938,7 +938,7 @@ abstract class PackageManager extends StateNotifier { OperationFailed( target: target, type: FailureType.downloadFailed, - message: 'Failed to access downloaded file. Please try again.', + message: 'Failed to access downloaded file.', description: '$e', ), ); @@ -975,7 +975,7 @@ abstract class PackageManager extends StateNotifier { OperationFailed( target: op.target, type: FailureType.downloadFailed, - message: 'Download link unavailable. Please try again later.', + message: 'Download link unavailable.', ), ); continue; @@ -1115,10 +1115,10 @@ abstract class PackageManager extends StateNotifier { final userMessage = isCertMismatch ? 'Update signed by different developer. Uninstall current version to update.' : isHashMismatch - ? 'File verification failed. Please try downloading again.' + ? 'Hash mismatch. Possibly a malicious file, aborting installation.' : isInvalidFile - ? 'Invalid app file. Please try downloading again.' - : 'Installation failed. Please try again.'; + ? 'Invalid app file. The download may be corrupt.' + : 'Installation failed.'; setOperation( appId, diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index 8aa0a51..64a7fff 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; -import 'package:zapstore/services/error_reporting_service.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/trusted_signers_service.dart'; @@ -46,7 +45,7 @@ class InstallButton extends ConsumerWidget { // Listen for errors to show toasts ref.listen(installOperationProvider(app.identifier), (prev, next) { if (next is OperationFailed && prev is! OperationFailed) { - _showErrorToast(context, ref, next); + _showErrorToast(context, next); } }); @@ -623,10 +622,7 @@ class InstallButton extends ConsumerWidget { if (context.mounted) { final errorMessage = e.toString(); if (!errorMessage.contains('cancelled')) { - context.showError( - 'Uninstall failed. Please try again.', - description: errorMessage, - ); + context.showError('Uninstall failed', description: errorMessage); } } } @@ -636,77 +632,50 @@ class InstallButton extends ConsumerWidget { final operation = ref.read(installOperationProvider(app.identifier)); if (operation is! OperationFailed) return; - context.showError( - operation.message, - description: operation.description, - actions: [ - ( - 'Report issue', - () async { - final reporter = ref.read(errorReportingServiceProvider); - final success = await reporter.reportUserError( - title: operation.message, - technicalDetails: operation.description, - ); - if (context.mounted) { - context.showInfo( - success ? 'Report sent' : 'Failed to send report', - ); - } - }, - ), - ], - ); + _showErrorDetails(context, operation); // Always clear error after showing it (reckless mode removed). final pm = ref.read(packageManagerProvider.notifier); pm.dismissError(app.identifier); } - void _showErrorToast( - BuildContext context, - WidgetRef ref, - OperationFailed operation, - ) { - final reportAction = ( - 'Report issue', - () async { - final reporter = ref.read(errorReportingServiceProvider); - final success = await reporter.reportUserError( - title: operation.message, - technicalDetails: operation.description, - ); - if (context.mounted) { - context.showInfo(success ? 'Report sent' : 'Failed to send report'); - } - }, - ); - - if (operation.type == FailureType.certMismatch) { - context.showError( - 'Update signed by different developer', - description: - 'To install this update, you\'ll need to uninstall the current version first. This will remove app data.', - actions: [reportAction], - ); - } else if (operation.type == FailureType.incompatible) { - context.showError( - 'Device incompatible', - description: - 'This app is not compatible with your device. It may require a different architecture or Android version.', - actions: [reportAction], - ); - } else if (operation.description != null) { - // Errors with descriptions (from Kotlin) are shown as toasts - context.showError( - operation.message, - description: operation.description, - actions: [reportAction], - ); + void _showErrorToast(BuildContext context, OperationFailed operation) { + // Show toast for errors with technical details or specific types + if (operation.description != null || + operation.type == FailureType.certMismatch || + operation.type == FailureType.incompatible) { + _showErrorDetails(context, operation); } // Other errors are shown when user taps the error button } + void _showErrorDetails(BuildContext context, OperationFailed operation) { + final errorText = [ + operation.message, + if (operation.description != null) '\n${operation.description}', + ].join(); + + context.showError( + operation.message, + description: operation.description, + actions: [ + ( + 'Copy', + () async { + await Clipboard.setData(ClipboardData(text: errorText)); + if (context.mounted) { + context.showInfo( + 'Copied to clipboard', + description: + 'Share on nostr or the zapstore Signal support group.', + ); + } + }, + ), + ], + ); + } + Future _showForceUpdateDialog( WidgetRef ref, BuildContext context, @@ -799,10 +768,7 @@ class InstallButton extends ConsumerWidget { } catch (e) { final errorMessage = e.toString(); if (context.mounted && !errorMessage.contains('cancelled')) { - context.showError( - 'Update failed. Please try again.', - description: errorMessage, - ); + context.showError('Update failed', description: errorMessage); } } } diff --git a/spec/guidelines/INVARIANTS.md b/spec/guidelines/INVARIANTS.md index 9c190c6..f37e4ed 100644 --- a/spec/guidelines/INVARIANTS.md +++ b/spec/guidelines/INVARIANTS.md @@ -45,6 +45,11 @@ If any invariant is violated, the implementation is incorrect. - All user-visible processes must have explicit states (loading, empty, success, error). - Silent failures are unacceptable. +## Widget State Management + +- Use Flutter Hooks (`HookWidget`, `HookConsumerWidget`) instead of `StatefulWidget` for local widget state. +- Use `useState`, `useAnimationController`, `useEffect`, etc. over manual `State` classes. + ## Reproducible Android builds