Improve error reporting

This commit is contained in:
franzap
2026-02-06 13:20:47 -03:00
parent 5883b671a9
commit 38dcc3981f
6 changed files with 186 additions and 421 deletions
-179
View File
@@ -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<int, DateTime> _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<void> 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<bool> 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>(
ErrorReportingService.new,
);
+132 -159
View File
@@ -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<double> _fadeAnimation;
late Animation<Offset> _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<Offset>(
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,
@@ -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.',
};
}
@@ -350,7 +350,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
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<PackageManagerState> {
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<PackageManagerState> {
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<PackageManagerState> {
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<PackageManagerState> {
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,
+37 -71
View File
@@ -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<void> _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);
}
}
}
+5
View File
@@ -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