From 045fcdd57eca9ebd27ee1fa8ddbb2065ba0a9cce Mon Sep 17 00:00:00 2001 From: alltheseas Date: Thu, 15 Jan 2026 22:55:54 -0600 Subject: [PATCH] Add crash report caching service - Add CrashReport model and CrashReportCacheService for local crash storage - Add kCrashReportPubkey constant for crash report recipient - Wire global error handler to cache crashes for later consent-based reporting Part of #268 Signed-off-by: alltheseas Co-Authored-By: Claude Opus 4.5 --- lib/constants/app_constants.dart | 5 + lib/main.dart | 29 ++- lib/services/crash_report_cache_service.dart | 247 +++++++++++++++++++ 3 files changed, 273 insertions(+), 8 deletions(-) create mode 100644 lib/services/crash_report_cache_service.dart diff --git a/lib/constants/app_constants.dart b/lib/constants/app_constants.dart index 3cde44e..89a4bef 100644 --- a/lib/constants/app_constants.dart +++ b/lib/constants/app_constants.dart @@ -39,6 +39,11 @@ bool appStackEventFilter(Map event) { return false; } +/// Public key for receiving crash reports +/// (npub137t4ugj4zama3fx6s0vejlltuslyy9s2h76clypzp57675kr7vyqrd60v8) +const kCrashReportPubkey = + '8f975e22551777d8a4da83d9997febe43e42160abfb58f90220d3daf52c3f308'; + /// Amber signer package ID const kAmberPackageId = 'com.greenart7c3.nostrsigner'; diff --git a/lib/main.dart b/lib/main.dart index 4b31c27..846eabf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io' show Platform; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -12,6 +13,7 @@ import 'package:purplebase/purplebase.dart'; import 'package:amber_signer/amber_signer.dart'; import 'package:zapstore/services/app_restart_service.dart'; import 'package:zapstore/services/background_update_service.dart'; +import 'package:zapstore/services/crash_report_cache_service.dart'; import 'package:zapstore/router.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/theme.dart'; @@ -53,15 +55,26 @@ void main() { }; } -/// Global error handler that reports errors via NIP-44 encrypted DMs +/// Global error handler that caches crashes for user-consented reporting void _errorHandler(Object exception, StackTrace? stack) { - // Report error asynchronously (fire and forget) - // TODO: Disabled until careful review - // unawaited( - // _providerContainer - // .read(errorReportingServiceProvider) - // .reportError(exception, stack), - // ); + // TODO: Uncomment before release - disabled for testing + // if (kDebugMode) return; + + // Try to get app version from package manager + String? appVersion; + try { + final pmState = _providerContainer.read(packageManagerProvider); + appVersion = pmState.installed[kZapstoreAppIdentifier]?.version; + } catch (_) { + // Package manager may not be initialized yet + } + + // Cache crash for later consent prompt (fire and forget) + unawaited( + _providerContainer + .read(crashReportCacheServiceProvider) + .cacheCrash(CrashReport.fromError(exception, stack, appVersion: appVersion)), + ); } class ZapstoreApp extends HookConsumerWidget { diff --git a/lib/services/crash_report_cache_service.dart b/lib/services/crash_report_cache_service.dart new file mode 100644 index 0000000..3da6433 --- /dev/null +++ b/lib/services/crash_report_cache_service.dart @@ -0,0 +1,247 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Maximum number of crash reports to store locally. +const _maxCrashReports = 99; + +/// Directory name for crash reports. +const _crashReportDir = 'crash_reports'; + +/// Model representing a cached crash report. +class CrashReport { + CrashReport({ + required this.id, + required this.timestamp, + required this.exceptionType, + required this.message, + this.stackTrace, + required this.platform, + required this.osVersion, + this.appVersion, + this.fatal = false, + }); + + factory CrashReport.fromJson(Map json) { + return CrashReport( + id: json['id'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + exceptionType: json['exceptionType'] as String, + message: json['message'] as String, + stackTrace: json['stackTrace'] as String?, + platform: json['platform'] as String, + osVersion: json['osVersion'] as String, + appVersion: json['appVersion'] as String?, + fatal: json['fatal'] as bool? ?? false, + ); + } + + factory CrashReport.fromError( + Object exception, + StackTrace? stackTrace, { + String? appVersion, + bool fatal = false, + }) { + return CrashReport( + id: DateTime.now().millisecondsSinceEpoch.toString(), + timestamp: DateTime.now(), + exceptionType: exception.runtimeType.toString(), + message: exception.toString(), + stackTrace: stackTrace?.toString(), + platform: Platform.operatingSystem, + osVersion: Platform.operatingSystemVersion, + appVersion: appVersion, + fatal: fatal, + ); + } + + final String id; + final DateTime timestamp; + final String exceptionType; + final String message; + final String? stackTrace; + final String platform; + final String osVersion; + final String? appVersion; + final bool fatal; + + Map toJson() { + return { + 'id': id, + 'timestamp': timestamp.toIso8601String(), + 'exceptionType': exceptionType, + 'message': message, + 'stackTrace': stackTrace, + 'platform': platform, + 'osVersion': osVersion, + 'appVersion': appVersion, + 'fatal': fatal, + }; + } + + /// Format the crash report for sending, with optional user comment. + String toReportString({String? userComment}) { + final buffer = StringBuffer(); + buffer.writeln('=== ZAPSTORE CRASH REPORT ==='); + buffer.writeln('Timestamp: ${timestamp.toUtc().toIso8601String()}'); + buffer.writeln('Platform: $platform'); + buffer.writeln('OS Version: $osVersion'); + if (appVersion != null) buffer.writeln('App Version: $appVersion'); + if (fatal) buffer.writeln('Fatal: yes'); + buffer.writeln(); + + if (userComment != null && userComment.trim().isNotEmpty) { + buffer.writeln('User Comment:'); + buffer.writeln(userComment.trim()); + buffer.writeln(); + } + + buffer.writeln('Exception: $exceptionType'); + buffer.writeln(message); + buffer.writeln(); + + if (stackTrace != null) { + buffer.writeln('Stack Trace:'); + final stackLines = stackTrace!.split('\n'); + final limitedStack = stackLines.take(50).join('\n'); + buffer.writeln(limitedStack); + if (stackLines.length > 50) { + buffer.writeln('... (${stackLines.length - 50} more lines)'); + } + } + + return buffer.toString(); + } +} + +/// Service for caching crash reports locally until user consents to send them. +class CrashReportCacheService { + CrashReportCacheService(); + + int _nextSlot = 0; + + Future _getCrashDir() async { + final dir = await getApplicationDocumentsDirectory(); + final crashDir = Directory('${dir.path}/$_crashReportDir'); + if (!await crashDir.exists()) { + await crashDir.create(recursive: true); + } + return crashDir; + } + + /// Cache a crash report to local storage. + Future cacheCrash(CrashReport report) async { + try { + final crashDir = await _getCrashDir(); + final slot = _nextSlot; + _nextSlot = (_nextSlot + 1) % _maxCrashReports; + + final file = File('${crashDir.path}/crash_$slot.json'); + await file.writeAsString(jsonEncode(report.toJson())); + } catch (_) { + // Silently fail - we don't want caching to cause more errors + } + } + + /// Get all pending crash reports, sorted by timestamp (newest first). + Future> getPendingCrashes() async { + try { + final crashDir = await _getCrashDir(); + if (!await crashDir.exists()) { + return []; + } + + final files = await crashDir + .list() + .where((entity) => + entity is File && entity.path.endsWith('.json')) + .cast() + .toList(); + + final crashes = []; + for (final file in files) { + try { + final content = await file.readAsString(); + final json = jsonDecode(content) as Map; + crashes.add(CrashReport.fromJson(json)); + } catch (_) { + // Skip corrupted files + } + } + + // Sort by timestamp, newest first + crashes.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + return crashes; + } catch (_) { + return []; + } + } + + /// Clear a specific crash report by ID. + Future clearCrash(String crashId) async { + try { + final crashDir = await _getCrashDir(); + if (!await crashDir.exists()) return; + + final files = await crashDir + .list() + .where((entity) => + entity is File && entity.path.endsWith('.json')) + .cast() + .toList(); + + for (final file in files) { + try { + final content = await file.readAsString(); + final json = jsonDecode(content) as Map; + if (json['id'] == crashId) { + await file.delete(); + return; + } + } catch (_) { + // Continue to next file + } + } + } catch (_) { + // Silently fail + } + } + + /// Clear all cached crash reports. + Future clearAllCrashes() async { + try { + final crashDir = await _getCrashDir(); + if (!await crashDir.exists()) return; + + final files = await crashDir + .list() + .where((entity) => + entity is File && entity.path.endsWith('.json')) + .cast() + .toList(); + + for (final file in files) { + try { + await file.delete(); + } catch (_) { + // Continue to next file + } + } + } catch (_) { + // Silently fail + } + } +} + +final crashReportCacheServiceProvider = Provider( + (ref) => CrashReportCacheService(), +); + +/// Provider for pending crash reports. +/// Use `ref.invalidate(pendingCrashesProvider)` after clearing crashes. +final pendingCrashesProvider = FutureProvider>((ref) async { + final cacheService = ref.watch(crashReportCacheServiceProvider); + return cacheService.getPendingCrashes(); +});