mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
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 <alltheseas@users.noreply.github.com> Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
dad27694a6
commit
045fcdd57e
@@ -39,6 +39,11 @@ bool appStackEventFilter(Map<String, dynamic> event) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Public key for receiving crash reports
|
||||
/// (npub137t4ugj4zama3fx6s0vejlltuslyy9s2h76clypzp57675kr7vyqrd60v8)
|
||||
const kCrashReportPubkey =
|
||||
'8f975e22551777d8a4da83d9997febe43e42160abfb58f90220d3daf52c3f308';
|
||||
|
||||
/// Amber signer package ID
|
||||
const kAmberPackageId = 'com.greenart7c3.nostrsigner';
|
||||
|
||||
|
||||
+21
-8
@@ -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 {
|
||||
|
||||
@@ -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<String, dynamic> 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<String, dynamic> 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<Directory> _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<void> 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<List<CrashReport>> 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<File>()
|
||||
.toList();
|
||||
|
||||
final crashes = <CrashReport>[];
|
||||
for (final file in files) {
|
||||
try {
|
||||
final content = await file.readAsString();
|
||||
final json = jsonDecode(content) as Map<String, dynamic>;
|
||||
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<void> 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<File>()
|
||||
.toList();
|
||||
|
||||
for (final file in files) {
|
||||
try {
|
||||
final content = await file.readAsString();
|
||||
final json = jsonDecode(content) as Map<String, dynamic>;
|
||||
if (json['id'] == crashId) {
|
||||
await file.delete();
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// Continue to next file
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all cached crash reports.
|
||||
Future<void> 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<File>()
|
||||
.toList();
|
||||
|
||||
for (final file in files) {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (_) {
|
||||
// Continue to next file
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final crashReportCacheServiceProvider = Provider<CrashReportCacheService>(
|
||||
(ref) => CrashReportCacheService(),
|
||||
);
|
||||
|
||||
/// Provider for pending crash reports.
|
||||
/// Use `ref.invalidate(pendingCrashesProvider)` after clearing crashes.
|
||||
final pendingCrashesProvider = FutureProvider<List<CrashReport>>((ref) async {
|
||||
final cacheService = ref.watch(crashReportCacheServiceProvider);
|
||||
return cacheService.getPendingCrashes();
|
||||
});
|
||||
Reference in New Issue
Block a user