diff --git a/lib/main.dart b/lib/main.dart index 9d6c9b2..7525861 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,7 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_data/flutter_data.dart'; import 'package:flutter_phoenix/flutter_phoenix.dart'; @@ -16,8 +16,8 @@ const kDbVersion = 1; /// Application entry point. /// - Initializes Riverpod (and Flutter Data local storage) -/// - Handles errors globally -/// - Calls router +/// - Handles application errors +/// - Triggers routing void main() { runZonedGuarded(() { runApp( @@ -62,23 +62,24 @@ Directory? _dir; void errorHandler(Object exception, StackTrace? stack) { debounce((exception: exception, stack: stack), (records) async { - // TODO Implement reporting - final map = records.groupSetsBy((err) => '${err.exception}\n${err.stack}'); - print(map.length); - - // final text = - // '${DateTime.now().toIso8601String()}\n${err.exception}\n${err.stack}'; - // // print('${err.exception}\n${err.stack}'); - - // final hash = sha256.convert(utf8.encode('${err.exception}\n${err.stack}')); - // print(hash); - // print(text); - // print('-----'); - + if (records.isEmpty) return; _dir ??= await getApplicationDocumentsDirectory(); - // // final file = File('${directory.path}/data.txt'); - // // await file.writeAsString(newContents); + final file = File('${_dir!.path}/errors.json'); - // collect system information + final errorMap = await file.exists() + ? Map.from(jsonDecode(await file.readAsString())) + : {}; + + for (final record in records) { + final full = + '${record.exception}${record.stack?.toString() ?? ''}${DateTime.now().toIso8601String()}'; + final key = full.split('\n').take(2).join(); + // Only keep longest stack of similar errors, prevents duplicates + if (full.length > (errorMap[key]?.length ?? 0)) { + errorMap[key] = full; + } + } + print(errorMap.length); + await file.writeAsString(jsonEncode(errorMap)); }); } diff --git a/lib/navigation/mobile_scaffold.dart b/lib/navigation/mobile_scaffold.dart index 4343196..78cc80a 100644 --- a/lib/navigation/mobile_scaffold.dart +++ b/lib/navigation/mobile_scaffold.dart @@ -17,7 +17,8 @@ class MobileScaffold extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // TODO Inefficient, should be able to query for this (we need to store app info in database) + // TODO Inefficient, should be able to query for this + // (need to store app status in database, local only) final appsToUpdate = ref.apps.watchAll().model.where((a) => a.canUpdate).length; return Scaffold( diff --git a/lib/navigation/router.dart b/lib/navigation/router.dart index faa97fa..805c011 100644 --- a/lib/navigation/router.dart +++ b/lib/navigation/router.dart @@ -139,7 +139,7 @@ final dataLibrariesInitializer = FutureProvider((ref) async { ref .read(relayMessageNotifierProvider.notifier) .initialize(['wss://relay.zap.store', 'wss://relay.nostr.band']); - // TODO Is this best place for this listener? + _lifecycleListener = AppLifecycleListener( onStateChange: (state) async { if (state == AppLifecycleState.resumed) { diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 04b60f6..3effaf2 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -60,7 +60,7 @@ class SettingsScreen extends HookConsumerWidget { if (controller.text.trim().isNotEmpty) { final text = '${controller.text.trim()} [from ${user.npub} on ${DateFormat('MMMM d, y').format(DateTime.now())}]'; - final event = BaseEvent(content: text).sign(kI); + final event = BaseEvent(kind: 1011, content: text).sign(kI); await ref.apps.nostrAdapter.notifier .publish(event, relayUrls: ['wss://relay.zap.store']); controller.clear(); diff --git a/lib/utils/debounce.dart b/lib/utils/debounce.dart index 452c82d..3ad57ef 100644 --- a/lib/utils/debounce.dart +++ b/lib/utils/debounce.dart @@ -4,13 +4,13 @@ const _debounceTime = 1200; Timer? timer; final acc = []; -void debounce(T input, Function(List) cb) { +void debounce(T input, Future Function(List) cb) { if (timer?.isActive ?? false) { timer!.cancel(); } acc.add(input); - timer = Timer(Duration(milliseconds: _debounceTime), () { - cb(acc.cast()); + timer = Timer(Duration(milliseconds: _debounceTime), () async { + await cb(acc.cast()); acc.clear(); }); } diff --git a/lib/widgets/error_container.dart b/lib/widgets/error_container.dart index af06e9f..44a75af 100644 --- a/lib/widgets/error_container.dart +++ b/lib/widgets/error_container.dart @@ -1,9 +1,15 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_data/flutter_data.dart'; import 'package:flutter_phoenix/flutter_phoenix.dart'; import 'package:gap/gap.dart'; +import 'package:http/http.dart' as http; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:purplebase/purplebase.dart'; +import 'package:zapstore/screens/settings_screen.dart'; +import 'package:zapstore/utils/system_info.dart'; import 'package:zapstore/utils/theme.dart'; class ErrorContainer extends HookConsumerWidget { @@ -51,43 +57,12 @@ class ErrorContainer extends HookConsumerWidget { textAlign: TextAlign.center, ), Gap(24), - Text( - 'First try restart. If it does not fix it, try clear.\nLastly, you can send us an error report.', - style: TextStyle( - fontSize: 15, - color: Colors.grey, - ), - textAlign: TextAlign.center, - ), - Gap(10), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - ElevatedButton( - onPressed: () { - Phoenix.rebirth(context); - context.go('/'); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Color.fromARGB(255, 255, 99, 99), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: const Text( - 'Restart', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - ), - Gap(10), ElevatedButton( onPressed: () { ref.read(localStorageProvider).destroy().then((_) { - print('destory done'); Phoenix.rebirth(context); context.go('/'); }); @@ -100,7 +75,7 @@ class ErrorContainer extends HookConsumerWidget { ), ), child: const Text( - 'Clear', + 'Clear and reload app', style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, @@ -109,7 +84,7 @@ class ErrorContainer extends HookConsumerWidget { ), Gap(10), ElevatedButton( - onPressed: () {}, + onPressed: () => _sendErrorReport(ref, exception, stack), style: ElevatedButton.styleFrom( backgroundColor: Color.fromARGB(255, 255, 22, 22), foregroundColor: Colors.white, @@ -118,7 +93,7 @@ class ErrorContainer extends HookConsumerWidget { ), ), child: const Text( - 'Report', + 'Report error', style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, @@ -150,3 +125,21 @@ Widget materialErrorBuilder(BuildContext context, Widget? widget) { if (widget != null) return widget; throw StateError('widget is null'); } + +Future _sendErrorReport( + WidgetRef ref, Object exception, StackTrace? stack) async { + final systemInfo = await ref.read(systemInfoProvider.future); + + var map = { + 'e': exception.toString(), + if (stack != null) 'stack': stack.toString(), + 'info': systemInfo.androidInfo.toString() + }; + + final client = http.Client(); + final event = BaseEvent(kind: 1011, content: jsonEncode(map)).sign(kI); + await client.post(Uri.parse('https://relay.zap.store/'), + body: jsonEncode(event.toMap()), + headers: {'Content-Type': 'application/json'}); + client.close(); +}