From 52b89d0d6141aecf8315cc3d440c355af8ff5b75 Mon Sep 17 00:00:00 2001 From: Jakub Homlala <1329033+jhomlala@users.noreply.github.com> Date: Wed, 3 Jul 2024 12:34:59 +0200 Subject: [PATCH] feat: Overall code refactor and unit tests introduction (#208) * feat: added unit tests, removed unused methods * feat: added unit tests, fixed invalid code * feat: added melos test script, added CI test step * feat: added tests * feat: added tests for memory storage * feat: added tests for alice core * refactor: general refactor of alice core * refactor: general refactor * fix: fixed tests * feat: added alice parser tests * feat: added equatable * fix: fixed issues * fix: fixed issues * fix: fixed issues * fix: changed headers definition * feat: added tests * refactor: refactored export helper * refactor: refactored export helper, added tests * refactor: dart format & minor fixes * refactor: refactor notifications * refactor: general refactor * refactor: fixed PR comments * feat: updated changelog --- .github/workflows/ci.yml | 14 ++ melos.yaml | 14 +- packages/alice/CHANGELOG.md | 6 + packages/alice/lib/alice.dart | 10 +- packages/alice/lib/core/alice_core.dart | 192 +++++------------ packages/alice/lib/core/alice_logger.dart | 13 +- .../alice/lib/core/alice_memory_storage.dart | 22 +- .../alice/lib/core/alice_notification.dart | 152 ++++++++++++++ packages/alice/lib/core/alice_storage.dart | 7 +- ...e_helper.dart => alice_export_helper.dart} | 198 ++++++++---------- .../alice/lib/helper/operating_system.dart | 14 ++ .../alice/lib/model/alice_export_result.dart | 20 ++ packages/alice/lib/model/alice_http_call.dart | 20 +- .../alice/lib/model/alice_http_error.dart | 7 +- .../alice/lib/model/alice_http_request.dart | 18 +- .../alice/lib/model/alice_http_response.dart | 13 +- .../page/alice_call_details_page.dart | 12 +- .../widget/alice_call_request_screen.dart | 6 +- .../widget/alice_call_response_screen.dart | 12 +- .../page/alice_calls_list_page.dart | 52 ++++- packages/alice/lib/utils/alice_parser.dart | 28 ++- packages/alice/lib/utils/curl.dart | 2 +- packages/alice/pubspec.yaml | 5 +- packages/alice/test/core/alice_core_test.dart | 109 ++++++++++ .../alice/test/core/alice_logger_test.dart | 59 ++++++ .../test/core/alice_memory_storage_test.dart | 147 +++++++++++++ .../helper/alice_conversion_helper_test.dart | 31 +++ .../test/helper/alice_export_helper_test.dart | 175 ++++++++++++++++ .../alice/test/mock/alice_logger_mock.dart | 4 + .../alice/test/mock/alice_storage_mock.dart | 4 + .../alice/test/mock/build_context_mock.dart | 4 + packages/alice/test/mocked_data.dart | 44 ++++ .../alice/test/utils/alice_parser_test.dart | 84 ++++++++ packages/alice_dio/lib/alice_dio_adapter.dart | 3 +- .../alice_http/lib/alice_http_adapter.dart | 4 +- .../lib/alice_http_client_adapter.dart | 5 +- .../alice_objectbox/lib/alice_objectbox.dart | 13 -- .../lib/model/cached_alice_http_call.dart | 22 ++ .../lib/model/cached_alice_http_error.dart | 6 + .../lib/model/cached_alice_http_request.dart | 27 ++- .../lib/model/cached_alice_http_response.dart | 12 ++ pubspec.yaml | 4 - 42 files changed, 1253 insertions(+), 341 deletions(-) create mode 100644 packages/alice/lib/core/alice_notification.dart rename packages/alice/lib/helper/{alice_save_helper.dart => alice_export_helper.dart} (60%) create mode 100644 packages/alice/lib/model/alice_export_result.dart create mode 100644 packages/alice/test/core/alice_core_test.dart create mode 100644 packages/alice/test/core/alice_logger_test.dart create mode 100644 packages/alice/test/core/alice_memory_storage_test.dart create mode 100644 packages/alice/test/helper/alice_conversion_helper_test.dart create mode 100644 packages/alice/test/helper/alice_export_helper_test.dart create mode 100644 packages/alice/test/mock/alice_logger_mock.dart create mode 100644 packages/alice/test/mock/alice_storage_mock.dart create mode 100644 packages/alice/test/mock/build_context_mock.dart create mode 100644 packages/alice/test/mocked_data.dart create mode 100644 packages/alice/test/utils/alice_parser_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea322ae3..268bfb87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,17 @@ jobs: run: ./.github/workflows/scripts/install-tools.sh - name: Check formatting run: melos format --output none --set-exit-if-changed + + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - name: Install Tools + run: ./.github/workflows/scripts/install-tools.sh + - name: Check tests + run: melos run test diff --git a/melos.yaml b/melos.yaml index 1b134772..3e0082f9 100644 --- a/melos.yaml +++ b/melos.yaml @@ -9,4 +9,16 @@ scripts: fix: exec: dart fix --apply format: - exec: dart format . \ No newline at end of file + exec: dart format . + test: + description: Run tests in a specific package. + run: flutter test + exec: + concurrency: 1 + packageFilters: + dirExists: + - test + # This tells Melos tests to ignore env variables passed to tests from `melos run test` + # as they could change the behaviour of how tests filter packages. + env: + MELOS_TEST: true \ No newline at end of file diff --git a/packages/alice/CHANGELOG.md b/packages/alice/CHANGELOG.md index d777bef6..d9ea6000 100644 --- a/packages/alice/CHANGELOG.md +++ b/packages/alice/CHANGELOG.md @@ -1,3 +1,9 @@ +# 1.0.0-dev.9 +* Fixed saving issue with Android 13 onwards. +* Added unit tests. +* Updated CI/CD task for tests. +* General refactor of code base. + # 1.0.0-dev.8 * Added storage abstractions (by Klemen Tusar https://github.com/techouse). * Added in memory storage implementation (by Klemen Tusar https://github.com/techouse). diff --git a/packages/alice/lib/alice.dart b/packages/alice/lib/alice.dart index 43c8cc37..7bca2583 100644 --- a/packages/alice/lib/alice.dart +++ b/packages/alice/lib/alice.dart @@ -1,5 +1,6 @@ import 'package:alice/core/alice_adapter.dart'; import 'package:alice/core/alice_core.dart'; +import 'package:alice/core/alice_logger.dart'; import 'package:alice/core/alice_memory_storage.dart'; import 'package:alice/core/alice_storage.dart'; import 'package:alice/model/alice_http_call.dart'; @@ -9,6 +10,7 @@ import 'package:flutter/widgets.dart'; export 'package:alice/core/alice_store.dart'; export 'package:alice/model/alice_log.dart'; +export 'package:alice/core/alice_memory_storage.dart'; class Alice { /// Should user be notified with notification when there's new request caught @@ -33,11 +35,15 @@ class Alice { /// Flag used to show/hide share button final bool? showShareButton; - GlobalKey? _navigatorKey; + /// Alice core instance late final AliceCore _aliceCore; + /// Alice storage instance final AliceStorage? _aliceStorage; + /// Navigator key used for navigating to inspector + GlobalKey? _navigatorKey; + /// Creates alice instance. Alice({ GlobalKey? navigatorKey, @@ -55,13 +61,13 @@ class Alice { showNotification: showNotification, showInspectorOnShake: showInspectorOnShake, notificationIcon: notificationIcon, - maxCallsCount: maxCallsCount, directionality: directionality, showShareButton: showShareButton, aliceStorage: _aliceStorage ?? AliceMemoryStorage( maxCallsCount: maxCallsCount, ), + aliceLogger: AliceLogger(), ); } diff --git a/packages/alice/lib/core/alice_core.dart b/packages/alice/lib/core/alice_core.dart index b0dc132c..ce5519b3 100644 --- a/packages/alice/lib/core/alice_core.dart +++ b/packages/alice/lib/core/alice_core.dart @@ -1,20 +1,19 @@ import 'dart:async' show FutureOr, StreamSubscription; -import 'dart:io' show Platform; import 'package:alice/core/alice_logger.dart'; import 'package:alice/core/alice_storage.dart'; import 'package:alice/core/alice_utils.dart'; -import 'package:alice/helper/alice_save_helper.dart'; +import 'package:alice/helper/alice_export_helper.dart'; +import 'package:alice/core/alice_notification.dart'; +import 'package:alice/helper/operating_system.dart'; +import 'package:alice/model/alice_export_result.dart'; import 'package:alice/model/alice_http_call.dart'; import 'package:alice/model/alice_http_error.dart'; import 'package:alice/model/alice_http_response.dart'; import 'package:alice/model/alice_log.dart'; -import 'package:alice/model/alice_translation.dart'; -import 'package:alice/ui/common/alice_context_ext.dart'; import 'package:alice/ui/common/alice_navigation.dart'; import 'package:alice/utils/shake_detector.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; typedef AliceOnCallsChanged = Future Function(List? calls); @@ -30,23 +29,11 @@ class AliceCore { /// Icon url for notification final String notificationIcon; + /// Storage used for Alice to keep calls data. final AliceStorage _aliceStorage; - late final NotificationDetails _notificationDetails = NotificationDetails( - android: AndroidNotificationDetails( - 'Alice', - 'Alice', - channelDescription: 'Alice', - enableVibration: false, - playSound: false, - largeIcon: DrawableResourceAndroidBitmap(notificationIcon), - ), - iOS: const DarwinNotificationDetails(presentSound: false), - ); - - ///Max number of calls that are stored in memory. When count is reached, FIFO - ///method queue will be used to remove elements. - final int maxCallsCount; + /// Logger used for Alice to keep logs; + final AliceLogger _aliceLogger; ///Directionality of app. If null then directionality of context will be used. final TextDirection? directionality; @@ -54,23 +41,20 @@ class AliceCore { ///Flag used to show/hide share button final bool? showShareButton; - final AliceLogger _aliceLogger = AliceLogger(); - - FlutterLocalNotificationsPlugin? _flutterLocalNotificationsPlugin; - + /// Navigator key used for inspector navigator. GlobalKey? navigatorKey; + /// Flag used to determine whether is inspector opened bool _isInspectorOpened = false; + /// Detector used to detect device shakes ShakeDetector? _shakeDetector; - StreamSubscription>? _callsSubscription; - - String? _notificationMessage; - - String? _notificationMessageShown; + /// Helper used for notification management + AliceNotification? _notification; - bool _notificationProcessing = false; + /// Subscription for call changes + StreamSubscription>? _callsSubscription; /// Creates alice core instance AliceCore( @@ -78,18 +62,22 @@ class AliceCore { required this.showNotification, required this.showInspectorOnShake, required this.notificationIcon, - required this.maxCallsCount, required AliceStorage aliceStorage, + required AliceLogger aliceLogger, this.directionality, this.showShareButton, - }) : _aliceStorage = aliceStorage { + }) : _aliceStorage = aliceStorage, + _aliceLogger = aliceLogger { + _subscribeToCallChanges(); if (showNotification) { - _initializeNotificationsPlugin(); - _requestNotificationPermissions(); - _aliceStorage.subscribeToCallChanges(onCallsChanged); + _notification = AliceNotification(); + _notification?.configure( + notificationIcon: notificationIcon, + openInspectorCallback: navigateToCallListScreen, + ); } if (showInspectorOnShake) { - if (Platform.isAndroid || Platform.isIOS) { + if (OperatingSystem.isAndroid || OperatingSystem.isMacOS) { _shakeDetector = ShakeDetector.autoStart( onPhoneShake: navigateToCallListScreen, shakeThresholdGravity: 4, @@ -101,49 +89,20 @@ class AliceCore { /// Dispose subjects and subscriptions void dispose() { _shakeDetector?.stopListening(); - _callsSubscription?.cancel(); + _unsubscribeFromCallChanges(); } - @protected - void _initializeNotificationsPlugin() { - _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - final AndroidInitializationSettings initializationSettingsAndroid = - AndroidInitializationSettings(notificationIcon); - const DarwinInitializationSettings initializationSettingsIOS = - DarwinInitializationSettings(); - const DarwinInitializationSettings initializationSettingsMacOS = - DarwinInitializationSettings(); - final InitializationSettings initializationSettings = - InitializationSettings( - android: initializationSettingsAndroid, - iOS: initializationSettingsIOS, - macOS: initializationSettingsMacOS, - ); - _flutterLocalNotificationsPlugin?.initialize( - initializationSettings, - onDidReceiveNotificationResponse: _onDidReceiveNotificationResponse, - ); - } - - @protected - Future onCallsChanged(List? calls) async { + /// Called when calls has been updated + Future _onCallsChanged(List? calls) async { if (calls != null && calls.isNotEmpty) { final AliceStats stats = _aliceStorage.getStats(); - - _notificationMessage = _getNotificationMessage(stats); - if (_notificationMessage != _notificationMessageShown && - !_notificationProcessing) { - await _showLocalNotification(stats); - } + _notification?.showStatsNotification( + context: getContext()!, + stats: stats, + ); } } - Future _onDidReceiveNotificationResponse( - NotificationResponse response, - ) async { - navigateToCallListScreen(); - } - /// Opens Http calls inspector. This will navigate user to the new fullscreen /// page where all listened http calls can be viewed. void navigateToCallListScreen() { @@ -165,67 +124,6 @@ class AliceCore { /// Get context from navigator key. Used to open inspector route. BuildContext? getContext() => navigatorKey?.currentState?.overlay?.context; - String _getNotificationMessage(AliceStats stats) => [ - if (stats.loading > 0) - '${getContext()?.i18n(AliceTranslationKey.notificationLoading)} ${stats.loading}', - if (stats.successes > 0) - '${getContext()?.i18n(AliceTranslationKey.notificationSuccess)} ${stats.successes}', - if (stats.redirects > 0) - '${getContext()?.i18n(AliceTranslationKey.notificationRedirect)} ${stats.redirects}', - if (stats.errors > 0) - '${getContext()?.i18n(AliceTranslationKey.notificationError)} ${stats.errors}', - ].join(' | '); - - Future _requestNotificationPermissions() async { - if (Platform.isIOS || Platform.isMacOS) { - await _flutterLocalNotificationsPlugin - ?.resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin>() - ?.requestPermissions( - alert: true, - badge: true, - sound: true, - ); - await _flutterLocalNotificationsPlugin - ?.resolvePlatformSpecificImplementation< - MacOSFlutterLocalNotificationsPlugin>() - ?.requestPermissions( - alert: true, - badge: true, - sound: true, - ); - } else if (Platform.isAndroid) { - final AndroidFlutterLocalNotificationsPlugin? androidImplementation = - _flutterLocalNotificationsPlugin - ?.resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>(); - - await androidImplementation?.requestNotificationsPermission(); - } - } - - Future _showLocalNotification(AliceStats stats) async { - try { - _notificationProcessing = true; - - final String? message = _notificationMessage; - - await _flutterLocalNotificationsPlugin?.show( - 0, - getContext() - ?.i18n(AliceTranslationKey.notificationTotalRequests) - .replaceAll("[requestCount]", stats.total.toString()), - message, - _notificationDetails, - payload: '', - ); - - _notificationMessageShown = message; - } finally { - _notificationProcessing = false; - } - } - /// Add alice http call to calls subject FutureOr addCall(AliceHttpCall call) => _aliceStorage.addCall(call); @@ -237,32 +135,42 @@ class AliceCore { FutureOr addResponse(AliceHttpResponse response, int requestId) => _aliceStorage.addResponse(response, requestId); - /// Add alice http call to calls subject - FutureOr addHttpCall(AliceHttpCall aliceHttpCall) => - _aliceStorage.addHttpCall(aliceHttpCall); - /// Remove all calls from calls subject FutureOr removeCalls() => _aliceStorage.removeCalls(); + /// Selects call with given [requestId]. It may return null. @protected AliceHttpCall? selectCall(int requestId) => _aliceStorage.selectCall(requestId); + /// Returns stream which returns list of HTTP calls Stream> get callsStream => _aliceStorage.callsStream; + /// Returns all stored HTTP calls. List getCalls() => _aliceStorage.getCalls(); - /// Save all calls to file - void saveHttpRequests(BuildContext context) { - AliceSaveHelper.saveCalls(context, _aliceStorage.getCalls()); + /// Save all calls to file. + Future saveCallsToFile(BuildContext context) { + return AliceExportHelper.saveCallsToFile(context, _aliceStorage.getCalls()); } /// Adds new log to Alice logger. - void addLog(AliceLog log) => _aliceLogger.logs.add(log); + void addLog(AliceLog log) => _aliceLogger.add(log); /// Adds list of logs to Alice logger - void addLogs(List logs) => _aliceLogger.logs.addAll(logs); + void addLogs(List logs) => _aliceLogger.addAll(logs); /// Returns flag which determines whether inspector is opened bool get isInspectorOpened => _isInspectorOpened; + + /// Subscribes to storage for call changes. + void _subscribeToCallChanges() { + _callsSubscription = _aliceStorage.callsStream.listen(_onCallsChanged); + } + + /// Unsubscribes storage for call changes. + void _unsubscribeFromCallChanges() { + _callsSubscription?.cancel(); + _callsSubscription = null; + } } diff --git a/packages/alice/lib/core/alice_logger.dart b/packages/alice/lib/core/alice_logger.dart index fbcd7185..e63f5010 100644 --- a/packages/alice/lib/core/alice_logger.dart +++ b/packages/alice/lib/core/alice_logger.dart @@ -1,5 +1,6 @@ -import 'dart:io' show Platform, Process, ProcessResult; +import 'dart:io' show Process, ProcessResult; +import 'package:alice/helper/operating_system.dart'; import 'package:alice/model/alice_log.dart'; import 'package:alice/utils/num_comparison.dart'; import 'package:flutter/foundation.dart'; @@ -30,6 +31,12 @@ class AliceLogger { } } + void addAll(List logs) { + for (var log in logs) { + add(log); + } + } + void add(AliceLog log) { late final int index; if (logs.isEmpty || !log.timestamp.isBefore(logs.last.timestamp)) { @@ -69,7 +76,7 @@ class AliceLogger { /// Returns raw logs from Android via ADB. Future getAndroidRawLogs() async { - if (Platform.isAndroid) { + if (OperatingSystem.isAndroid) { final ProcessResult process = await Process.run('logcat', ['-v', 'raw', '-d']); return process.stdout as String; @@ -79,7 +86,7 @@ class AliceLogger { /// Clears all raw logs. Future clearAndroidRawLogs() async { - if (Platform.isAndroid) { + if (OperatingSystem.isAndroid) { await Process.run('logcat', ['-c']); } } diff --git a/packages/alice/lib/core/alice_memory_storage.dart b/packages/alice/lib/core/alice_memory_storage.dart index eea6ab08..dffbfaaf 100644 --- a/packages/alice/lib/core/alice_memory_storage.dart +++ b/packages/alice/lib/core/alice_memory_storage.dart @@ -1,4 +1,5 @@ -import 'package:alice/core/alice_core.dart'; +import 'dart:async'; + import 'package:alice/core/alice_storage.dart'; import 'package:alice/core/alice_utils.dart'; import 'package:alice/model/alice_http_call.dart'; @@ -59,12 +60,8 @@ class AliceMemoryStorage implements AliceStorage { final int callsCount = callsSubject.value.length; if (callsCount >= maxCallsCount) { final List originalCalls = callsSubject.value; - final List calls = [...originalCalls]..sort( - (AliceHttpCall call1, AliceHttpCall call2) => - call1.createdTime.compareTo(call2.createdTime), - ); - final int indexToReplace = originalCalls.indexOf(calls.first); - originalCalls[indexToReplace] = call; + originalCalls.removeAt(0); + originalCalls.add(call); callsSubject.add(originalCalls); } else { @@ -84,13 +81,6 @@ class AliceMemoryStorage implements AliceStorage { callsSubject.add([...callsSubject.value]); } - @override - void addHttpCall(AliceHttpCall aliceHttpCall) { - assert(aliceHttpCall.request != null, "Http call request can't be null"); - assert(aliceHttpCall.response != null, "Http call response can't be null"); - callsSubject.add([...callsSubject.value, aliceHttpCall]); - } - @override void addResponse(AliceHttpResponse response, int requestId) { final AliceHttpCall? selectedCall = selectCall(requestId); @@ -114,8 +104,4 @@ class AliceMemoryStorage implements AliceStorage { @override AliceHttpCall? selectCall(int requestId) => callsSubject.value .firstWhereOrNull((AliceHttpCall call) => call.id == requestId); - - @override - void subscribeToCallChanges(AliceOnCallsChanged callback) => - callsSubject.listen(callback); } diff --git a/packages/alice/lib/core/alice_notification.dart b/packages/alice/lib/core/alice_notification.dart new file mode 100644 index 00000000..41cbcfe4 --- /dev/null +++ b/packages/alice/lib/core/alice_notification.dart @@ -0,0 +1,152 @@ +import 'package:alice/core/alice_storage.dart'; +import 'package:alice/core/alice_utils.dart'; +import 'package:alice/helper/operating_system.dart'; +import 'package:alice/model/alice_translation.dart'; +import 'package:alice/ui/common/alice_context_ext.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +/// Helper for displaying local notifications. +class AliceNotification { + /// Notification plugin instance + FlutterLocalNotificationsPlugin? _flutterLocalNotificationsPlugin; + + /// Notification configuration for Alice. + late final NotificationDetails _notificationDetails; + + /// Callback used to open inspector on notification click. + late final void Function() _openInspectorCallback; + + /// Currently displayed notification message + String? _notificationMessageDisplayed; + + /// Is current notification being processed + bool _isNotificationProcessing = false; + + /// Configures local notifications with [notificationIcon] and + /// [openInspectorCallback]. + void configure({ + required String notificationIcon, + required void Function() openInspectorCallback, + }) { + _openInspectorCallback = openInspectorCallback; + _notificationDetails = NotificationDetails( + android: AndroidNotificationDetails( + 'Alice', + 'Alice', + channelDescription: 'Alice', + enableVibration: false, + playSound: false, + largeIcon: DrawableResourceAndroidBitmap(notificationIcon), + ), + iOS: const DarwinNotificationDetails(presentSound: false), + ); + + _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + final AndroidInitializationSettings initializationSettingsAndroid = + AndroidInitializationSettings(notificationIcon); + const DarwinInitializationSettings initializationSettingsIOS = + DarwinInitializationSettings(); + const DarwinInitializationSettings initializationSettingsMacOS = + DarwinInitializationSettings(); + final InitializationSettings initializationSettings = + InitializationSettings( + android: initializationSettingsAndroid, + iOS: initializationSettingsIOS, + macOS: initializationSettingsMacOS, + ); + _flutterLocalNotificationsPlugin?.initialize( + initializationSettings, + onDidReceiveNotificationResponse: _onDidReceiveNotificationResponse, + ); + _requestNotificationPermissions(); + } + + /// Requests notification permissions to display stats notification. + Future _requestNotificationPermissions() async { + if (OperatingSystem.isIOS || OperatingSystem.isMacOS) { + await _flutterLocalNotificationsPlugin + ?.resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin>() + ?.requestPermissions( + alert: true, + badge: true, + sound: true, + ); + await _flutterLocalNotificationsPlugin + ?.resolvePlatformSpecificImplementation< + MacOSFlutterLocalNotificationsPlugin>() + ?.requestPermissions( + alert: true, + badge: true, + sound: true, + ); + } else if (OperatingSystem.isAndroid) { + final AndroidFlutterLocalNotificationsPlugin? androidImplementation = + _flutterLocalNotificationsPlugin + ?.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + + await androidImplementation?.requestNotificationsPermission(); + } + } + + /// Called when notification has been clicked. It navigates to calls screen. + Future _onDidReceiveNotificationResponse( + NotificationResponse response, + ) async { + _openInspectorCallback(); + } + + /// Formats [stats] for notification message. + String _getNotificationMessage( + {required BuildContext context, required AliceStats stats}) => + [ + if (stats.loading > 0) + '${context.i18n(AliceTranslationKey.notificationLoading)} ${stats.loading}', + if (stats.successes > 0) + '${context.i18n(AliceTranslationKey.notificationSuccess)} ${stats.successes}', + if (stats.redirects > 0) + '${context.i18n(AliceTranslationKey.notificationRedirect)} ${stats.redirects}', + if (stats.errors > 0) + '${context.i18n(AliceTranslationKey.notificationError)} ${stats.errors}', + ].join(' | '); + + /// Shows current stats notification. It formats [stats] into simple + /// notification which is displayed when stats has changed. + Future showStatsNotification({ + required BuildContext context, + required AliceStats stats, + }) async { + try { + if (_isNotificationProcessing) { + return; + } + final message = _getNotificationMessage( + context: context, + stats: stats, + ); + if (message == _notificationMessageDisplayed) { + return; + } + + _isNotificationProcessing = true; + + await _flutterLocalNotificationsPlugin?.show( + 0, + context + .i18n(AliceTranslationKey.notificationTotalRequests) + .replaceAll("[requestCount]", stats.total.toString()), + message, + _notificationDetails, + payload: '', + ); + + _notificationMessageDisplayed = message; + } catch (error) { + AliceUtils.log(error.toString()); + } finally { + _isNotificationProcessing = false; + } + } +} diff --git a/packages/alice/lib/core/alice_storage.dart b/packages/alice/lib/core/alice_storage.dart index 792f2b32..d1808882 100644 --- a/packages/alice/lib/core/alice_storage.dart +++ b/packages/alice/lib/core/alice_storage.dart @@ -1,7 +1,6 @@ +import 'package:alice/model/alice_http_call.dart'; import 'dart:async' show FutureOr; -import 'package:alice/core/alice_core.dart'; -import 'package:alice/model/alice_http_call.dart'; import 'package:alice/model/alice_http_error.dart'; import 'package:alice/model/alice_http_response.dart'; @@ -30,9 +29,5 @@ abstract interface class AliceStorage { FutureOr addResponse(AliceHttpResponse response, int requestId); - FutureOr addHttpCall(AliceHttpCall aliceHttpCall); - FutureOr removeCalls(); - - void subscribeToCallChanges(AliceOnCallsChanged callback); } diff --git a/packages/alice/lib/helper/alice_save_helper.dart b/packages/alice/lib/helper/alice_export_helper.dart similarity index 60% rename from packages/alice/lib/helper/alice_save_helper.dart rename to packages/alice/lib/helper/alice_export_helper.dart index 35aaa9b5..7e956335 100644 --- a/packages/alice/lib/helper/alice_save_helper.dart +++ b/packages/alice/lib/helper/alice_export_helper.dart @@ -1,155 +1,126 @@ // ignore_for_file: use_build_context_synchronously import 'dart:convert' show JsonEncoder; -import 'dart:io' show Directory, File, FileMode, IOSink, Platform; +import 'dart:io' show Directory, File, FileMode, IOSink; import 'package:alice/core/alice_utils.dart'; import 'package:alice/helper/alice_conversion_helper.dart'; import 'package:alice/helper/operating_system.dart'; +import 'package:alice/model/alice_export_result.dart'; import 'package:alice/model/alice_http_call.dart'; import 'package:alice/model/alice_translation.dart'; import 'package:alice/ui/common/alice_context_ext.dart'; -import 'package:alice/ui/common/alice_dialog.dart'; import 'package:alice/utils/alice_parser.dart'; import 'package:alice/utils/curl.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:open_filex/open_filex.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:share_plus/share_plus.dart'; -class AliceSaveHelper { +class AliceExportHelper { static const JsonEncoder _encoder = JsonEncoder.withIndent(' '); - /// Top level method used to save calls to file - static void saveCalls( + /// Format log based on [call] and tries to share it. + static Future shareCall({ + required BuildContext context, + required AliceHttpCall call, + }) async { + final callLog = + await AliceExportHelper.buildFullCallLog(call: call, context: context); + + if (callLog == null) { + return AliceExportResult( + success: false, + error: AliceExportResultError.logGenerate, + ); + } + + await Share.share( + callLog, + subject: context.i18n(AliceTranslationKey.emailSubject), + ); + + return AliceExportResult(success: true); + } + + /// Format log based on [calls] and saves it to file. + static Future saveCallsToFile( BuildContext context, List calls, - ) { - _checkPermissions(context, calls); + ) async { + final bool permissionStatus = await _getPermissionStatus(); + if (!permissionStatus) { + final bool status = await _requestPermission(); + if (!status) { + return AliceExportResult( + success: false, + error: AliceExportResultError.permission, + ); + } + } + + return await _saveToFile(context, calls); } + /// Returns current storage permission status. Checks permission for iOS + /// For other platforms it returns true. static Future _getPermissionStatus() async { - if (Platform.isAndroid || Platform.isIOS) { + if (OperatingSystem.isIOS) { return Permission.storage.status.isGranted; } else { return true; } } + /// Requests permissions for storage for iOS. For other platforms it doesn't + /// make any action and returns true. static Future _requestPermission() async { - if (Platform.isAndroid || Platform.isIOS) { + if (OperatingSystem.isIOS) { return Permission.storage.request().isGranted; } else { return true; } } - static Future _checkPermissions( - BuildContext context, - List calls, - ) async { - final bool permissionStatus = await _getPermissionStatus(); - - if (!context.mounted) return; - - if (permissionStatus) { - await _saveToFile(context, calls); - } else { - final bool status = await _requestPermission(); - - if (!context.mounted) return; - - if (status) { - await _saveToFile(context, calls); - } else { - AliceGeneralDialog.show( - context: context, - title: - context.i18n(AliceTranslationKey.saveDialogPermissionErrorTitle), - description: context - .i18n(AliceTranslationKey.saveDialogPermissionErrorDescription), - ); - } - } - } - - static Future _saveToFile( + /// Saves [calls] to file. For android it uses external storage directory and + /// for ios it uses application documents directory. + static Future _saveToFile( BuildContext context, List calls, ) async { try { if (calls.isEmpty) { - AliceGeneralDialog.show( - context: context, - title: context.i18n(AliceTranslationKey.saveDialogEmptyErrorTitle), - description: - context.i18n(AliceTranslationKey.saveDialogEmptyErrorDescription), - ); - return ''; + return AliceExportResult( + success: false, error: AliceExportResultError.empty); } - final Directory? externalDir = switch (Platform.operatingSystem) { - OperatingSystem.android => await getExternalStorageDirectory(), - OperatingSystem.ios => await getApplicationDocumentsDirectory(), - _ => await getApplicationCacheDirectory(), - }; - - if (externalDir != null) { - final String fileName = - 'alice_log_${DateTime.now().millisecondsSinceEpoch}.txt'; - final File file = File('${externalDir.path}/$fileName')..createSync(); - final IOSink sink = file.openWrite(mode: FileMode.append) - ..write(await _buildAliceLog(context: context)); - for (final AliceHttpCall call in calls) { - sink.write(_buildCallLog(context: context, call: call)); - } - await sink.flush(); - await sink.close(); - - if (context.mounted) { - AliceGeneralDialog.show( - context: context, - title: context.i18n(AliceTranslationKey.saveSuccessTitle), - description: context - .i18n(AliceTranslationKey.saveSuccessDescription) - .replaceAll("[path]", file.path), - secondButtonTitle: Platform.isAndroid - ? context.i18n(AliceTranslationKey.saveSuccessView) - : null, - secondButtonAction: () => - Platform.isAndroid ? OpenFilex.open(file.path) : null, - ); - } - - return file.path; - } else { - if (context.mounted) { - AliceGeneralDialog.show( - context: context, - title: - context.i18n(AliceTranslationKey.saveDialogFileSaveErrorTitle), - description: context - .i18n(AliceTranslationKey.saveDialogFileSaveErrorDescription), - ); - } + final Directory externalDir = await getApplicationCacheDirectory(); + final String fileName = + 'alice_log_${DateTime.now().millisecondsSinceEpoch}.txt'; + final File file = File('${externalDir.path}/$fileName')..createSync(); + final IOSink sink = file.openWrite(mode: FileMode.append) + ..write(await _buildAliceLog(context: context)); + for (final AliceHttpCall call in calls) { + sink.write(_buildCallLog(context: context, call: call)); } + await sink.flush(); + await sink.close(); + + return AliceExportResult( + success: true, + path: file.path, + ); } catch (exception) { - if (context.mounted) { - AliceGeneralDialog.show( - context: context, - title: context.i18n(AliceTranslationKey.saveDialogFileSaveErrorTitle), - description: context - .i18n(AliceTranslationKey.saveDialogFileSaveErrorDescription), - ); - AliceUtils.log(exception.toString()); - } + AliceUtils.log(exception.toString()); + return AliceExportResult( + success: false, + error: AliceExportResultError.file, + ); } - - return ''; } + /// Builds log string based on data collected from package info. static Future _buildAliceLog({required BuildContext context}) async { final PackageInfo packageInfo = await PackageInfo.fromPlatform(); @@ -162,8 +133,11 @@ class AliceSaveHelper { '\n'; } - static String _buildCallLog( - {required BuildContext context, required AliceHttpCall call}) { + /// Build log string based on [call]. + static String _buildCallLog({ + required BuildContext context, + required AliceHttpCall call, + }) { final StringBuffer stringBuffer = StringBuffer() ..writeAll([ '===========================================\n', @@ -196,7 +170,7 @@ class AliceSaveHelper { stringBuffer.writeAll([ '${context.i18n(AliceTranslationKey.saveLogRequestSize)} ${AliceConversionHelper.formatBytes(call.request?.size ?? 0)}\n', - '${context.i18n(AliceTranslationKey.saveLogRequestBody)} ${AliceBodyParser.formatBody(context: context, body: call.request?.body, contentType: call.request?.contentType)}\n', + '${context.i18n(AliceTranslationKey.saveLogRequestBody)} ${AliceParser.formatBody(context: context, body: call.request?.body, contentType: call.request?.contentType)}\n', '--------------------------------------------\n', '${context.i18n(AliceTranslationKey.saveLogResponse)}\n', '--------------------------------------------\n', @@ -204,7 +178,7 @@ class AliceSaveHelper { '${context.i18n(AliceTranslationKey.saveLogResponseStatus)} ${call.response?.status}\n', '${context.i18n(AliceTranslationKey.saveLogResponseSize)} ${AliceConversionHelper.formatBytes(call.response?.size ?? 0)}\n', '${context.i18n(AliceTranslationKey.saveLogResponseHeaders)} ${_encoder.convert(call.response?.headers)}\n', - '${context.i18n(AliceTranslationKey.saveLogResponseBody)} ${AliceBodyParser.formatBody(context: context, body: call.response?.body, contentType: AliceBodyParser.getContentType(context: context, headers: call.response?.headers))}\n', + '${context.i18n(AliceTranslationKey.saveLogResponseBody)} ${AliceParser.formatBody(context: context, body: call.response?.body, contentType: AliceParser.getContentType(context: context, headers: call.response?.headers))}\n', ]); if (call.error != null) { @@ -234,8 +208,11 @@ class AliceSaveHelper { return stringBuffer.toString(); } - static Future buildCallLog( - {required BuildContext context, required AliceHttpCall call}) async { + /// Builds full call log string (package info log and call log). + static Future buildFullCallLog({ + required BuildContext context, + required AliceHttpCall call, + }) async { try { return await _buildAliceLog(context: context) + _buildCallLog( @@ -243,7 +220,8 @@ class AliceSaveHelper { context: context, ); } catch (exception) { - return 'Failed to generate call log'; + AliceUtils.log("Failed to generate call log: $exception"); + return null; } } } diff --git a/packages/alice/lib/helper/operating_system.dart b/packages/alice/lib/helper/operating_system.dart index 3adb8d70..44b5941a 100644 --- a/packages/alice/lib/helper/operating_system.dart +++ b/packages/alice/lib/helper/operating_system.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; + /// Definition of OS. abstract class OperatingSystem { static const String android = 'android'; @@ -6,4 +8,16 @@ abstract class OperatingSystem { static const String linux = 'linux'; static const String macos = 'macos'; static const String windows = 'windows'; + + static bool get isAndroid => defaultTargetPlatform == TargetPlatform.android; + + static bool get isIOS => defaultTargetPlatform == TargetPlatform.iOS; + + static bool get isMacOS => defaultTargetPlatform == TargetPlatform.macOS; + + static bool get isWindows => defaultTargetPlatform == TargetPlatform.windows; + + static bool get isLinux => defaultTargetPlatform == TargetPlatform.linux; + + static bool get isFuchsia => defaultTargetPlatform == TargetPlatform.fuchsia; } diff --git a/packages/alice/lib/model/alice_export_result.dart b/packages/alice/lib/model/alice_export_result.dart new file mode 100644 index 00000000..b1904ad1 --- /dev/null +++ b/packages/alice/lib/model/alice_export_result.dart @@ -0,0 +1,20 @@ +/// Model of export result. +class AliceExportResult { + final bool success; + final AliceExportResultError? error; + final String? path; + + AliceExportResult({ + required this.success, + this.error, + this.path, + }); +} + +/// Definition of all possible export errors. +enum AliceExportResultError { + logGenerate, + empty, + permission, + file, +} diff --git a/packages/alice/lib/model/alice_http_call.dart b/packages/alice/lib/model/alice_http_call.dart index eac7bb6e..675cf57f 100644 --- a/packages/alice/lib/model/alice_http_call.dart +++ b/packages/alice/lib/model/alice_http_call.dart @@ -1,9 +1,10 @@ import 'package:alice/model/alice_http_error.dart'; import 'package:alice/model/alice_http_request.dart'; import 'package:alice/model/alice_http_response.dart'; +import 'package:equatable/equatable.dart'; /// Definition of http calls data holder. -class AliceHttpCall { +class AliceHttpCall with EquatableMixin { AliceHttpCall(this.id) { loading = true; createdTime = DateTime.now(); @@ -28,4 +29,21 @@ class AliceHttpCall { this.response = response; loading = false; } + + @override + List get props => [ + id, + createdTime, + client, + loading, + secure, + method, + endpoint, + server, + uri, + duration, + request, + response, + error + ]; } diff --git a/packages/alice/lib/model/alice_http_error.dart b/packages/alice/lib/model/alice_http_error.dart index 7e0b5a76..e54920d1 100644 --- a/packages/alice/lib/model/alice_http_error.dart +++ b/packages/alice/lib/model/alice_http_error.dart @@ -1,5 +1,10 @@ +import 'package:equatable/equatable.dart'; + /// Definition of http error data holder. -class AliceHttpError { +class AliceHttpError with EquatableMixin { dynamic error; StackTrace? stackTrace; + + @override + List get props => [error, stackTrace]; } diff --git a/packages/alice/lib/model/alice_http_request.dart b/packages/alice/lib/model/alice_http_request.dart index 4addaaa9..3f860c42 100644 --- a/packages/alice/lib/model/alice_http_request.dart +++ b/packages/alice/lib/model/alice_http_request.dart @@ -2,16 +2,30 @@ import 'dart:io' show Cookie; import 'package:alice/model/alice_form_data_file.dart'; import 'package:alice/model/alice_from_data_field.dart'; +import 'package:equatable/equatable.dart'; /// Definition of http request data holder. -class AliceHttpRequest { +class AliceHttpRequest with EquatableMixin { int size = 0; DateTime time = DateTime.now(); - Map headers = {}; + Map headers = {}; dynamic body = ''; String? contentType = ''; List cookies = []; Map queryParameters = {}; List? formDataFiles; List? formDataFields; + + @override + List get props => [ + size, + time, + headers, + body, + contentType, + cookies, + queryParameters, + formDataFiles, + formDataFields, + ]; } diff --git a/packages/alice/lib/model/alice_http_response.dart b/packages/alice/lib/model/alice_http_response.dart index e237a9f3..5990b004 100644 --- a/packages/alice/lib/model/alice_http_response.dart +++ b/packages/alice/lib/model/alice_http_response.dart @@ -1,8 +1,19 @@ +import 'package:equatable/equatable.dart'; + /// Definition of http response data holder. -class AliceHttpResponse { +class AliceHttpResponse with EquatableMixin { int? status = 0; int size = 0; DateTime time = DateTime.now(); dynamic body; Map? headers; + + @override + List get props => [ + status, + size, + time, + body, + headers, + ]; } diff --git a/packages/alice/lib/ui/call_details/page/alice_call_details_page.dart b/packages/alice/lib/ui/call_details/page/alice_call_details_page.dart index c8b049f1..1e46cc9f 100644 --- a/packages/alice/lib/ui/call_details/page/alice_call_details_page.dart +++ b/packages/alice/lib/ui/call_details/page/alice_call_details_page.dart @@ -1,7 +1,7 @@ // ignore_for_file: use_build_context_synchronously import 'package:alice/core/alice_core.dart'; -import 'package:alice/helper/alice_save_helper.dart'; +import 'package:alice/helper/alice_export_helper.dart'; import 'package:alice/model/alice_http_call.dart'; import 'package:alice/model/alice_translation.dart'; import 'package:alice/ui/call_details/model/alice_call_details_tab.dart'; @@ -14,7 +14,6 @@ import 'package:alice/ui/common/alice_page.dart'; import 'package:alice/ui/common/alice_theme.dart'; import 'package:collection/collection.dart' show IterableExtension; import 'package:flutter/material.dart'; -import 'package:share_plus/share_plus.dart'; /// Call details page which displays 4 tabs: overview, request, response, error. class AliceCallDetailsPage extends StatefulWidget { @@ -79,7 +78,7 @@ class _AliceCallDetailsPageState extends State ? FloatingActionButton( backgroundColor: AliceTheme.lightRed, key: const Key('share_key'), - onPressed: () async => _saveCallsToFile(), + onPressed: _shareCall, child: const Icon( Icons.share, color: AliceTheme.white, @@ -103,11 +102,8 @@ class _AliceCallDetailsPageState extends State ); } - void _saveCallsToFile() async { - await Share.share( - await AliceSaveHelper.buildCallLog(call: widget.call, context: context), - subject: context.i18n(AliceTranslationKey.emailSubject), - ); + void _shareCall() async { + await AliceExportHelper.shareCall(context: context, call: widget.call); } /// Get tab name based on [item] type. diff --git a/packages/alice/lib/ui/call_details/widget/alice_call_request_screen.dart b/packages/alice/lib/ui/call_details/widget/alice_call_request_screen.dart index 2e5416d0..7d10e4fa 100644 --- a/packages/alice/lib/ui/call_details/widget/alice_call_request_screen.dart +++ b/packages/alice/lib/ui/call_details/widget/alice_call_request_screen.dart @@ -28,7 +28,7 @@ class AliceCallRequestScreen extends StatelessWidget { value: AliceConversionHelper.formatBytes(call.request?.size ?? 0)), AliceCallListRow( name: context.i18n(AliceTranslationKey.callRequestContentType), - value: AliceBodyParser.getContentType( + value: AliceParser.getContentType( context: context, headers: call.request?.headers)), ]; @@ -105,10 +105,10 @@ class AliceCallRequestScreen extends StatelessWidget { String _getBodyContent({required BuildContext context}) { final dynamic body = call.request?.body; return body != null - ? AliceBodyParser.formatBody( + ? AliceParser.formatBody( context: context, body: body, - contentType: AliceBodyParser.getContentType( + contentType: AliceParser.getContentType( context: context, headers: call.request?.headers), ) : context.i18n(AliceTranslationKey.callRequestBodyEmpty); diff --git a/packages/alice/lib/ui/call_details/widget/alice_call_response_screen.dart b/packages/alice/lib/ui/call_details/widget/alice_call_response_screen.dart index e8790dc9..17289eae 100644 --- a/packages/alice/lib/ui/call_details/widget/alice_call_response_screen.dart +++ b/packages/alice/lib/ui/call_details/widget/alice_call_response_screen.dart @@ -170,7 +170,7 @@ class _BodyDataColumnState extends State<_BodyDataColumn> { } String? _getContentTypeOfResponse() { - return AliceBodyParser.getContentType( + return AliceParser.getContentType( context: context, headers: call.response?.headers); } @@ -301,11 +301,11 @@ class _TextBody extends StatelessWidget { @override Widget build(BuildContext context) { final Map? headers = call.response?.headers; - final String bodyContent = AliceBodyParser.formatBody( + final String bodyContent = AliceParser.formatBody( context: context, body: call.response?.body, contentType: - AliceBodyParser.getContentType(context: context, headers: headers), + AliceParser.getContentType(context: context, headers: headers), ); return AliceCallListRow( name: context.i18n(AliceTranslationKey.callResponseBody), @@ -358,15 +358,15 @@ class _UnknownBody extends StatelessWidget { Widget build(BuildContext context) { final Map? headers = call.response?.headers; final String contentType = - AliceBodyParser.getContentType(context: context, headers: headers) ?? + AliceParser.getContentType(context: context, headers: headers) ?? context.i18n(AliceTranslationKey.callResponseHeadersUnknown); if (showUnsupportedBody) { - final bodyContent = AliceBodyParser.formatBody( + final bodyContent = AliceParser.formatBody( context: context, body: call.response?.body, contentType: - AliceBodyParser.getContentType(context: context, headers: headers), + AliceParser.getContentType(context: context, headers: headers), ); return AliceCallListRow( name: context.i18n(AliceTranslationKey.callResponseBody), diff --git a/packages/alice/lib/ui/calls_list/page/alice_calls_list_page.dart b/packages/alice/lib/ui/calls_list/page/alice_calls_list_page.dart index 455d3631..2b0beb6a 100644 --- a/packages/alice/lib/ui/calls_list/page/alice_calls_list_page.dart +++ b/packages/alice/lib/ui/calls_list/page/alice_calls_list_page.dart @@ -1,5 +1,9 @@ +// ignore_for_file: use_build_context_synchronously + import 'package:alice/core/alice_core.dart'; import 'package:alice/core/alice_logger.dart'; +import 'package:alice/helper/operating_system.dart'; +import 'package:alice/model/alice_export_result.dart'; import 'package:alice/model/alice_http_call.dart'; import 'package:alice/model/alice_translation.dart'; import 'package:alice/ui/call_details/model/alice_menu_item.dart'; @@ -14,6 +18,7 @@ import 'package:alice/ui/common/alice_page.dart'; import 'package:alice/ui/calls_list/widget/alice_logs_screen.dart'; import 'package:alice/ui/common/alice_theme.dart'; import 'package:flutter/material.dart'; +import 'package:open_filex/open_filex.dart'; /// Page which displays list of calls caught by Alice. It displays tab view /// where calls and logs can be inspected. It allows to sort calls, delete calls @@ -252,7 +257,52 @@ class _AliceCallsListPageState extends State } /// Called when save to file has been pressed. It saves data to file. - void _saveToFile() => aliceCore.saveHttpRequests(context); + void _saveToFile() async { + final result = await aliceCore.saveCallsToFile(context); + if (result.success) { + AliceGeneralDialog.show( + context: context, + title: context.i18n(AliceTranslationKey.saveSuccessTitle), + description: context + .i18n(AliceTranslationKey.saveSuccessDescription) + .replaceAll("[path]", result.path!), + secondButtonTitle: OperatingSystem.isAndroid + ? context.i18n(AliceTranslationKey.saveSuccessView) + : null, + secondButtonAction: () => + OperatingSystem.isAndroid ? OpenFilex.open(result.path) : null, + ); + } else { + final [String title, String description] = switch (result.error) { + AliceExportResultError.logGenerate => [ + context.i18n(AliceTranslationKey.saveDialogPermissionErrorTitle), + context + .i18n(AliceTranslationKey.saveDialogPermissionErrorDescription), + ], + AliceExportResultError.empty => [ + context.i18n(AliceTranslationKey.saveDialogEmptyErrorTitle), + context.i18n(AliceTranslationKey.saveDialogEmptyErrorDescription), + ], + AliceExportResultError.permission => [ + context.i18n(AliceTranslationKey.saveDialogPermissionErrorTitle), + context + .i18n(AliceTranslationKey.saveDialogPermissionErrorDescription), + ], + AliceExportResultError.file => [ + context.i18n(AliceTranslationKey.saveDialogFileSaveErrorTitle), + context + .i18n(AliceTranslationKey.saveDialogFileSaveErrorDescription), + ], + _ => ["", ""], + }; + + AliceGeneralDialog.show( + context: context, + title: title, + description: description, + ); + } + } /// Filters calls based on query. void _updateSearchQuery(String query) => setState(() {}); diff --git a/packages/alice/lib/utils/alice_parser.dart b/packages/alice/lib/utils/alice_parser.dart index f7432c20..df5874b7 100644 --- a/packages/alice/lib/utils/alice_parser.dart +++ b/packages/alice/lib/utils/alice_parser.dart @@ -5,17 +5,17 @@ import 'package:alice/ui/common/alice_context_ext.dart'; import 'package:flutter/material.dart'; /// Body parser helper used to parsing body data. -class AliceBodyParser { +class AliceParser { static const String _jsonContentTypeSmall = 'content-type'; static const String _jsonContentTypeBig = 'Content-Type'; static const String _stream = 'Stream'; static const String _applicationJson = 'application/json'; - static const JsonEncoder encoder = JsonEncoder.withIndent(' '); + static const JsonEncoder _encoder = JsonEncoder.withIndent(' '); /// Tries to parse json. If it fails, it will return the json itself. static String _parseJson(dynamic json) { try { - return encoder.convert(json); + return _encoder.convert(json); } catch (_) { return json.toString(); } @@ -79,16 +79,30 @@ class AliceBodyParser { /// Get content type from [headers]. It looks for json and if it can't find /// it, it will return unknown content type. - static String? getContentType( - {required BuildContext context, Map? headers}) { + static String? getContentType({ + required BuildContext context, + Map? headers, + }) { if (headers != null) { if (headers.containsKey(_jsonContentTypeSmall)) { - return headers[_jsonContentTypeSmall] as String?; + return headers[_jsonContentTypeSmall]; } if (headers.containsKey(_jsonContentTypeBig)) { - return headers[_jsonContentTypeBig] as String?; + return headers[_jsonContentTypeBig]; } } return context.i18n(AliceTranslationKey.unknown); } + + static Map parseHeaders({dynamic headers}) { + if (headers is Map) { + return headers; + } + + if (headers is Map) { + return headers.map((key, value) => MapEntry(key, value.toString())); + } + + throw ArgumentError("Invalid headers value."); + } } diff --git a/packages/alice/lib/utils/curl.dart b/packages/alice/lib/utils/curl.dart index 10877ad7..ad4e980d 100644 --- a/packages/alice/lib/utils/curl.dart +++ b/packages/alice/lib/utils/curl.dart @@ -8,7 +8,7 @@ String getCurlCommand(AliceHttpCall call) { curlCmd.write(' -X ${call.method}'); - for (final MapEntry header + for (final MapEntry header in call.request?.headers.entries ?? []) { if (header.key.toLowerCase() == HttpHeaders.acceptEncodingHeader && header.value.toString().toLowerCase() == 'gzip') { diff --git a/packages/alice/pubspec.yaml b/packages/alice/pubspec.yaml index 6c82b106..e7a2359b 100644 --- a/packages/alice/pubspec.yaml +++ b/packages/alice/pubspec.yaml @@ -1,6 +1,6 @@ name: alice description: Alice is an HTTP Inspector tool which helps debugging http requests. It catches and stores http requests and responses, which can be viewed via simple UI. -version: 1.0.0-dev.8 +version: 1.0.0-dev.9 homepage: https://github.com/jhomlala/alice repository: https://github.com/jhomlala/alice @@ -25,5 +25,8 @@ dependencies: dev_dependencies: flutter_lints: ^4.0.0 + flutter_test: + sdk: flutter lints: ^4.0.0 test: ^1.25.2 + mocktail: ^1.0.4 \ No newline at end of file diff --git a/packages/alice/test/core/alice_core_test.dart b/packages/alice/test/core/alice_core_test.dart new file mode 100644 index 00000000..f1d503ec --- /dev/null +++ b/packages/alice/test/core/alice_core_test.dart @@ -0,0 +1,109 @@ +import 'package:alice/alice.dart'; +import 'package:alice/core/alice_core.dart'; +import 'package:alice/core/alice_logger.dart'; +import 'package:alice/core/alice_storage.dart'; +import 'package:alice/model/alice_http_error.dart'; +import 'package:alice/model/alice_http_response.dart'; +import 'package:flutter/material.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +import '../mock/alice_logger_mock.dart'; +import '../mock/alice_storage_mock.dart'; +import '../mocked_data.dart'; + +void main() { + late AliceCore aliceCore; + late AliceStorage aliceStorage; + late AliceLogger aliceLogger; + + setUp(() { + registerFallbackValue(MockedData.getLoadingHttpCall()); + registerFallbackValue(AliceHttpError()); + registerFallbackValue(AliceHttpResponse()); + registerFallbackValue(AliceLog(message: "")); + aliceStorage = AliceStorageMock(); + aliceLogger = AliceLoggerMock(); + + when(() => aliceStorage.callsStream) + .thenAnswer((_) => const Stream.empty()); + aliceCore = AliceCore( + GlobalKey(), + showNotification: false, + showInspectorOnShake: false, + notificationIcon: "", + aliceStorage: aliceStorage, + aliceLogger: aliceLogger, + ); + }); + + group("AliceCore", () { + test("should use storage to add call", () { + when(() => aliceStorage.addCall(any())).thenAnswer((_) => () {}); + + aliceCore.addCall(MockedData.getLoadingHttpCall()); + + verify(() => aliceStorage.addCall(any())); + }); + + test("should use storage to add error", () { + when(() => aliceStorage.addError(any(), any())).thenAnswer((_) => () {}); + + aliceCore.addError(AliceHttpError(), 0); + + verify(() => aliceStorage.addError(any(), any())); + }); + + test("should use storage to add response", () { + when(() => aliceStorage.addResponse(any(), any())) + .thenAnswer((_) => () {}); + + aliceCore.addResponse(AliceHttpResponse(), 0); + + verify(() => aliceStorage.addResponse(any(), any())); + }); + + test("should use storage to remove calls", () { + when(() => aliceStorage.removeCalls()).thenAnswer((_) => () {}); + + aliceCore.removeCalls(); + + verify(() => aliceStorage.removeCalls()); + }); + + test("should use storage to get calls stream", () async { + final calls = [MockedData.getLoadingHttpCall()]; + when(() => aliceStorage.callsStream) + .thenAnswer((_) => Stream.value(calls)); + + expect(await aliceCore.callsStream.first, calls); + + verify(() => aliceStorage.callsStream); + }); + + test("should use storage to get calls", () { + final calls = [MockedData.getLoadingHttpCall()]; + when(() => aliceStorage.getCalls()).thenAnswer((_) => calls); + + expect(aliceCore.getCalls(), calls); + + verify(() => aliceStorage.getCalls()); + }); + + test("should use logger to add log", () { + when(() => aliceLogger.add(any())).thenAnswer((_) => {}); + + aliceCore.addLog(AliceLog(message: "test")); + + verify(() => aliceCore.addLog(any())); + }); + + test("should use logger to add logs", () { + when(() => aliceLogger.addAll(any())).thenAnswer((_) => {}); + + aliceCore.addLogs([AliceLog(message: "test")]); + + verify(() => aliceCore.addLogs(any())); + }); + }); +} diff --git a/packages/alice/test/core/alice_logger_test.dart b/packages/alice/test/core/alice_logger_test.dart new file mode 100644 index 00000000..a46e0ce5 --- /dev/null +++ b/packages/alice/test/core/alice_logger_test.dart @@ -0,0 +1,59 @@ +import 'package:alice/core/alice_logger.dart'; +import 'package:alice/model/alice_log.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +void main() { + late AliceLogger aliceLogger; + setUp(() { + aliceLogger = AliceLogger(maximumSize: 1000); + }); + + group("AliceLogger", () { + test("should add log", () { + final log = AliceLog(message: "test"); + + aliceLogger.add(log); + + expect(aliceLogger.logs, [log]); + }); + + test("should add logs", () { + final logs = [AliceLog(message: "test"), AliceLog(message: "test2")]; + + aliceLogger.addAll(logs); + + expect(aliceLogger.logs, logs); + }); + + test("should clear logs", () { + final logs = [ + AliceLog(message: "test"), + AliceLog(message: "test2"), + ]; + + aliceLogger.addAll(logs); + + expect(aliceLogger.logs.isNotEmpty, true); + + aliceLogger.clearLogs(); + + expect(aliceLogger.logs.isEmpty, true); + }); + + test("should set maximum size", () { + final logs = [ + AliceLog(message: "test"), + AliceLog(message: "test2"), + ]; + + aliceLogger.addAll(logs); + + expect(aliceLogger.logs.length, 2); + + aliceLogger.maximumSize = 1; + + expect(aliceLogger.logs.length, 1); + }); + }); +} diff --git a/packages/alice/test/core/alice_memory_storage_test.dart b/packages/alice/test/core/alice_memory_storage_test.dart new file mode 100644 index 00000000..32ba44df --- /dev/null +++ b/packages/alice/test/core/alice_memory_storage_test.dart @@ -0,0 +1,147 @@ +import 'package:alice/core/alice_memory_storage.dart'; +import 'package:alice/model/alice_http_call.dart'; +import 'package:alice/model/alice_http_error.dart'; +import 'package:alice/model/alice_http_request.dart'; +import 'package:alice/model/alice_http_response.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +import '../mocked_data.dart'; + +void main() { + late AliceMemoryStorage storage; + setUp(() { + storage = AliceMemoryStorage(maxCallsCount: 1000); + }); + + group("AliceMemoryStorage", () { + test("should return HTTP call stats", () { + expect(storage.getStats(), ( + total: 0, + successes: 0, + redirects: 0, + errors: 0, + loading: 0, + )); + + storage + .addCall(MockedData.getHttpCallWithResponseStatus(statusCode: 200)); + storage + .addCall(MockedData.getHttpCallWithResponseStatus(statusCode: 201)); + storage + .addCall(MockedData.getHttpCallWithResponseStatus(statusCode: 300)); + storage + .addCall(MockedData.getHttpCallWithResponseStatus(statusCode: 301)); + storage + .addCall(MockedData.getHttpCallWithResponseStatus(statusCode: 400)); + storage + .addCall(MockedData.getHttpCallWithResponseStatus(statusCode: 404)); + storage + .addCall(MockedData.getHttpCallWithResponseStatus(statusCode: 500)); + storage.addCall(MockedData.getLoadingHttpCall()); + + expect(storage.getStats(), ( + total: 8, + successes: 2, + redirects: 2, + errors: 3, + loading: 1, + )); + }); + + test("should save HTTP calls", () { + final httpCall = AliceHttpCall(1); + httpCall.request = AliceHttpRequest(); + httpCall.response = AliceHttpResponse(); + + storage.addCall(httpCall); + expect(storage.getCalls(), [httpCall]); + + final anotherHttpCall = AliceHttpCall(1); + anotherHttpCall.request = AliceHttpRequest(); + anotherHttpCall.response = AliceHttpResponse(); + + storage.addCall(anotherHttpCall); + expect(storage.getCalls(), [httpCall, anotherHttpCall]); + }); + + test("should replace HTTP call if over limit", () { + storage = AliceMemoryStorage(maxCallsCount: 2); + final firstCall = MockedData.getLoadingHttpCall(); + final secondCall = MockedData.getLoadingHttpCall(); + final thirdCall = MockedData.getLoadingHttpCall(); + + storage.addCall(firstCall); + storage.addCall(secondCall); + storage.addCall(thirdCall); + + expect(storage.getCalls(), [secondCall, thirdCall]); + }); + + test("should add error to HTTP call", () { + final call = MockedData.getLoadingHttpCall(); + final error = AliceHttpError()..error = "Some error"; + + storage.addCall(call); + storage.addError(error, call.id); + + expect(storage.getCalls().first.error != null, true); + }); + + test("should not add error to HTTP call if HTTP has been not found", () { + final call = MockedData.getLoadingHttpCall(); + final error = AliceHttpError()..error = "Some error"; + + storage.addCall(call); + storage.addError(error, 100); + + expect(storage.getCalls().first.error != null, false); + }); + + test("should add response to HTTP call", () { + final call = MockedData.getLoadingHttpCall(); + final response = AliceHttpResponse(); + + storage.addCall(call); + storage.addResponse(response, call.id); + + final savedCall = storage.getCalls().first; + expect(savedCall.response != null, true); + expect(savedCall.loading, false); + expect(savedCall.duration > 0, true); + }); + + test("should not add response to HTTP call if HTTP has been not found", () { + final call = MockedData.getLoadingHttpCall(); + final response = AliceHttpResponse(); + + storage.addCall(call); + storage.addResponse(response, 100); + + expect(storage.getCalls().first.response != null, false); + }); + + test("should remove all calls", () { + storage.addCall(MockedData.getLoadingHttpCall()); + storage.addCall(MockedData.getLoadingHttpCall()); + storage.addCall(MockedData.getLoadingHttpCall()); + + expect(storage.getCalls().length, 3); + + storage.removeCalls(); + + expect(storage.getCalls().length, 0); + }); + + test("should return call if call exists", () { + final call = MockedData.getHttpCall(id: 0); + storage.addCall(call); + + expect(call, storage.selectCall(0)); + }); + + test("should return null if call doesn't exist", () { + expect(null, storage.selectCall(1)); + }); + }); +} diff --git a/packages/alice/test/helper/alice_conversion_helper_test.dart b/packages/alice/test/helper/alice_conversion_helper_test.dart new file mode 100644 index 00000000..3e9d5c9b --- /dev/null +++ b/packages/alice/test/helper/alice_conversion_helper_test.dart @@ -0,0 +1,31 @@ +import 'package:alice/helper/alice_conversion_helper.dart'; +import 'package:test/test.dart'; + +void main() { + group("AliceConversionHelper", () { + test("should format bytes", () { + expect(AliceConversionHelper.formatBytes(-100), "-1 B"); + expect(AliceConversionHelper.formatBytes(0), "0 B"); + expect(AliceConversionHelper.formatBytes(100), "100 B"); + expect(AliceConversionHelper.formatBytes(999), "999 B"); + expect(AliceConversionHelper.formatBytes(1000), "1000 B"); + expect(AliceConversionHelper.formatBytes(1001), "1.00 kB"); + expect(AliceConversionHelper.formatBytes(100000), "100.00 kB"); + expect(AliceConversionHelper.formatBytes(1000000), "1000.00 kB"); + expect(AliceConversionHelper.formatBytes(1000001), "1.00 MB"); + expect(AliceConversionHelper.formatBytes(100000000), "100.00 MB"); + }); + + test("should format time", () { + expect(AliceConversionHelper.formatTime(-100), "-1 ms"); + expect(AliceConversionHelper.formatTime(0), "0 ms"); + expect(AliceConversionHelper.formatTime(100), "100 ms"); + expect(AliceConversionHelper.formatTime(1000), "1000 ms"); + expect(AliceConversionHelper.formatTime(1001), "1.00 s"); + expect(AliceConversionHelper.formatTime(5000), "5.00 s"); + expect(AliceConversionHelper.formatTime(60000), "60.00 s"); + expect(AliceConversionHelper.formatTime(60001), "1 min 0 s 1 ms"); + expect(AliceConversionHelper.formatTime(85000), "1 min 25 s 0 ms"); + }); + }); +} diff --git a/packages/alice/test/helper/alice_export_helper_test.dart b/packages/alice/test/helper/alice_export_helper_test.dart new file mode 100644 index 00000000..94452a1e --- /dev/null +++ b/packages/alice/test/helper/alice_export_helper_test.dart @@ -0,0 +1,175 @@ +import 'dart:io'; + +import 'package:alice/helper/alice_export_helper.dart'; +import 'package:alice/model/alice_export_result.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../mock/build_context_mock.dart'; +import '../mocked_data.dart'; + +void main() { + late BuildContext context; + setUp(() { + context = BuildContextMock(); + }); + + group("AliceExportHelper", () { + test("should build correct call log", () async { + _setPackageInfo(); + + final result = await AliceExportHelper.buildFullCallLog( + context: context, call: MockedData.getFilledHttpCall()); + _verifyLogLines(result!); + }); + + test("should save call log to file", () async { + TestWidgetsFlutterBinding.ensureInitialized(); + _setPackageInfo(); + _setPathProvider(); + _setDefaultTargetPlatform(); + + final result = await AliceExportHelper.saveCallsToFile( + context, [MockedData.getFilledHttpCall()]); + expect(result.success, true); + expect(result.path != null, true); + expect(result.error, null); + + final file = File(result.path!); + expect(file.existsSync(), true); + final content = await file.readAsString(); + _verifyLogLines(content); + file.delete(); + }); + }); + + test("should not save empty call log to file", () async { + TestWidgetsFlutterBinding.ensureInitialized(); + _setPackageInfo(); + _setPathProvider(); + _setDefaultTargetPlatform(); + + final result = await AliceExportHelper.saveCallsToFile(context, []); + + expect(result.success, false); + expect(result.path, null); + expect(result.error, AliceExportResultError.empty); + }); + + test("should not save call log to file if file problem occurs", () async { + TestWidgetsFlutterBinding.ensureInitialized(); + _setPackageInfo(); + _setPathProvider(isFailing: true); + _setDefaultTargetPlatform(); + + final result = await AliceExportHelper.saveCallsToFile( + context, [MockedData.getFilledHttpCall()]); + + expect(result.success, false); + expect(result.path, null); + expect(result.error, AliceExportResultError.file); + }); + + test("should share call log", () async { + TestWidgetsFlutterBinding.ensureInitialized(); + _setPackageInfo(); + _setShare(); + + final result = await AliceExportHelper.shareCall( + context: context, call: MockedData.getFilledHttpCall()); + expect(result.success, true); + expect(result.error, null); + }); +} + +void _verifyLogLines(String result) { + var lines = [ + 'AliceTranslationKey.saveHeaderTitle', + 'AliceTranslationKey.saveHeaderAppName Alice', + 'AliceTranslationKey.saveHeaderPackage pl.hasoft.alice', + 'AliceTranslationKey.saveHeaderTitle 1.0', + 'AliceTranslationKey.saveHeaderBuildNumber 1', + 'AliceTranslationKey.saveHeaderGenerated', + '', + '===========================================', + 'AliceTranslationKey.saveLogId', + '============================================', + '--------------------------------------------', + 'AliceTranslationKey.saveLogGeneralData', + '--------------------------------------------', + 'AliceTranslationKey.saveLogServer https://test.com ', + 'AliceTranslationKey.saveLogMethod POST ', + 'AliceTranslationKey.saveLogEndpoint /test ', + 'AliceTranslationKey.saveLogClient ', + 'AliceTranslationKey.saveLogDuration 0 ms', + 'AliceTranslationKey.saveLogSecured true', + 'AliceTranslationKey.saveLogCompleted: true ', + '--------------------------------------------', + 'AliceTranslationKey.saveLogRequest', + '--------------------------------------------', + 'AliceTranslationKey.saveLogRequestTime', + 'AliceTranslationKey.saveLogRequestContentType: application/json', + 'AliceTranslationKey.saveLogRequestCookies []', + 'AliceTranslationKey.saveLogRequestHeaders {}', + 'AliceTranslationKey.saveLogRequestSize 0 B', + 'AliceTranslationKey.saveLogRequestBody {', + ' "id": 0', + '}', + '--------------------------------------------', + 'AliceTranslationKey.saveLogResponse', + '--------------------------------------------', + 'AliceTranslationKey.saveLogResponseTime', + 'AliceTranslationKey.saveLogResponseStatus 0', + 'AliceTranslationKey.saveLogResponseSize 0 B', + 'AliceTranslationKey.saveLogResponseHeaders {}', + 'AliceTranslationKey.saveLogResponseBody {"id": 0}', + '--------------------------------------------', + 'AliceTranslationKey.saveLogCurl', + '--------------------------------------------', + 'curl -X POST ', + '==============================================', + '', + ]; + for (var line in lines) { + expect(result.contains(line), true); + } +} + +void _setPackageInfo() { + PackageInfo.setMockInitialValues( + appName: "Alice", + packageName: "pl.hasoft.alice", + version: "1.0", + buildNumber: "1", + buildSignature: "buildSignature", + ); +} + +void _setPathProvider({bool isFailing = false}) { + const MethodChannel channel = + MethodChannel('plugins.flutter.io/path_provider'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (isFailing) { + return ""; + } else { + return "."; + } + }); +} + +void _setDefaultTargetPlatform() { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; +} + +void _setShare() { + const MethodChannel channel = + MethodChannel('dev.fluttercommunity.plus/share'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return "."; + }); +} diff --git a/packages/alice/test/mock/alice_logger_mock.dart b/packages/alice/test/mock/alice_logger_mock.dart new file mode 100644 index 00000000..5384f9c3 --- /dev/null +++ b/packages/alice/test/mock/alice_logger_mock.dart @@ -0,0 +1,4 @@ +import 'package:alice/core/alice_logger.dart'; +import 'package:mocktail/mocktail.dart'; + +class AliceLoggerMock extends Mock implements AliceLogger {} diff --git a/packages/alice/test/mock/alice_storage_mock.dart b/packages/alice/test/mock/alice_storage_mock.dart new file mode 100644 index 00000000..1a3ab997 --- /dev/null +++ b/packages/alice/test/mock/alice_storage_mock.dart @@ -0,0 +1,4 @@ +import 'package:alice/core/alice_storage.dart'; +import 'package:mocktail/mocktail.dart'; + +class AliceStorageMock extends Mock implements AliceStorage {} diff --git a/packages/alice/test/mock/build_context_mock.dart b/packages/alice/test/mock/build_context_mock.dart new file mode 100644 index 00000000..705bfdd3 --- /dev/null +++ b/packages/alice/test/mock/build_context_mock.dart @@ -0,0 +1,4 @@ +import 'package:flutter/material.dart'; +import 'package:mocktail/mocktail.dart'; + +class BuildContextMock extends Mock implements BuildContext {} diff --git a/packages/alice/test/mocked_data.dart b/packages/alice/test/mocked_data.dart new file mode 100644 index 00000000..0a1a8c4d --- /dev/null +++ b/packages/alice/test/mocked_data.dart @@ -0,0 +1,44 @@ +import 'package:alice/model/alice_http_call.dart'; +import 'package:alice/model/alice_http_request.dart'; +import 'package:alice/model/alice_http_response.dart'; + +class MockedData { + static AliceHttpCall getHttpCallWithResponseStatus({ + required int statusCode, + }) { + final httpCall = AliceHttpCall(DateTime.now().millisecondsSinceEpoch) + ..loading = false; + httpCall.request = AliceHttpRequest(); + httpCall.response = AliceHttpResponse()..status = statusCode; + return httpCall; + } + + static AliceHttpCall getLoadingHttpCall() { + final httpCall = AliceHttpCall(DateTime.now().millisecondsSinceEpoch); + return httpCall; + } + + static AliceHttpCall getHttpCall({required int id}) { + final httpCall = AliceHttpCall(id); + return httpCall; + } + + static AliceHttpCall getFilledHttpCall() => + AliceHttpCall(DateTime.now().microsecondsSinceEpoch) + ..loading = false + ..request = (AliceHttpRequest() + ..headers = {} + ..body = '{"id": 0}' + ..contentType = "application/json" + ..size = 0 + ..time = DateTime.now()) + ..response = (AliceHttpResponse() + ..headers = {} + ..body = '{"id": 0}' + ..size = 0 + ..time = DateTime.now()) + ..method = "POST" + ..endpoint = "/test" + ..server = "https://test.com" + ..secure = true; +} diff --git a/packages/alice/test/utils/alice_parser_test.dart b/packages/alice/test/utils/alice_parser_test.dart new file mode 100644 index 00000000..68e75008 --- /dev/null +++ b/packages/alice/test/utils/alice_parser_test.dart @@ -0,0 +1,84 @@ +import 'package:alice/model/alice_translation.dart'; +import 'package:alice/utils/alice_parser.dart'; +import 'package:flutter/material.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +import '../mock/build_context_mock.dart'; + +void main() { + late BuildContext context; + setUp(() { + registerFallbackValue(AliceTranslationKey.accept); + context = BuildContextMock(); + }); + + group("AliceBodyParser", () { + test("should parse json body and pretty print it", () { + expect( + AliceParser.formatBody( + context: context, + body: '{"id": 1, "name": "test}', + contentType: "application/json"), + '"{\\"id\\": 1, \\"name\\": \\"test}"', + ); + }); + + test("should parse unknown body", () { + expect( + AliceParser.formatBody( + context: context, + body: 'test', + ), + 'test', + ); + }); + + test("should parse empty body", () { + expect( + AliceParser.formatBody( + context: context, + body: '', + ), + AliceTranslationKey.callRequestBodyEmpty.toString(), + ); + }); + + test("should parse application/json content type", () { + expect( + AliceParser.getContentType( + context: context, + headers: {'Content-Type': "application/json"}, + ), + "application/json"); + + expect( + AliceParser.getContentType( + context: context, + headers: {'content-type': "application/json"}, + ), + "application/json"); + }); + + test("should parse unknown content type", () { + expect( + AliceParser.getContentType( + context: context, + headers: {}, + ), + AliceTranslationKey.unknown.toString()); + }); + + test("should parse headers", () { + expect(AliceParser.parseHeaders(headers: {"id": 0}), {"id": "0"}); + expect(AliceParser.parseHeaders(headers: {"id": "0"}), {"id": "0"}); + }); + + test("should not parse headers", () { + expect( + () => AliceParser.parseHeaders(headers: "test"), + throwsArgumentError, + ); + }); + }); +} diff --git a/packages/alice_dio/lib/alice_dio_adapter.dart b/packages/alice_dio/lib/alice_dio_adapter.dart index 3bc91c86..f9908a84 100644 --- a/packages/alice_dio/lib/alice_dio_adapter.dart +++ b/packages/alice_dio/lib/alice_dio_adapter.dart @@ -8,6 +8,7 @@ import 'package:alice/model/alice_http_error.dart'; import 'package:alice/model/alice_http_request.dart'; import 'package:alice/model/alice_http_response.dart'; import 'package:alice/model/alice_log.dart'; +import 'package:alice/utils/alice_parser.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; @@ -75,7 +76,7 @@ class AliceDioAdapter extends InterceptorsWrapper with AliceAdapter { request ..time = DateTime.now() - ..headers = options.headers + ..headers = AliceParser.parseHeaders(headers: options.headers) ..contentType = options.contentType.toString() ..queryParameters = options.queryParameters; diff --git a/packages/alice_http/lib/alice_http_adapter.dart b/packages/alice_http/lib/alice_http_adapter.dart index 0022339d..bfb5bae0 100644 --- a/packages/alice_http/lib/alice_http_adapter.dart +++ b/packages/alice_http/lib/alice_http_adapter.dart @@ -41,7 +41,7 @@ class AliceHttpAdapter with AliceAdapter { httpRequest ..body = body ?? (response.request! as http.Request).body ?? '' ..size = utf8.encode(httpRequest.body.toString()).length - ..headers = Map.from(response.request!.headers); + ..headers = Map.from(response.request!.headers); } else if (body == null) { httpRequest ..size = 0 @@ -56,7 +56,7 @@ class AliceHttpAdapter with AliceAdapter { String? contentType = 'unknown'; if (httpRequest.headers.containsKey('Content-Type')) { - contentType = httpRequest.headers['Content-Type'] as String?; + contentType = httpRequest.headers['Content-Type']; } httpRequest diff --git a/packages/alice_http_client/lib/alice_http_client_adapter.dart b/packages/alice_http_client/lib/alice_http_client_adapter.dart index ae1044c7..f53b2921 100644 --- a/packages/alice_http_client/lib/alice_http_client_adapter.dart +++ b/packages/alice_http_client/lib/alice_http_client_adapter.dart @@ -5,6 +5,7 @@ import 'package:alice/core/alice_adapter.dart'; import 'package:alice/model/alice_http_call.dart'; import 'package:alice/model/alice_http_request.dart'; import 'package:alice/model/alice_http_response.dart'; +import 'package:alice/utils/alice_parser.dart'; class AliceHttpClientAdapter with AliceAdapter { /// Handles httpClientRequest and creates http alice call from it @@ -43,10 +44,10 @@ class AliceHttpClientAdapter with AliceAdapter { headers[header] = value; }); - httpRequest.headers = headers; + httpRequest.headers = AliceParser.parseHeaders(headers: headers); String? contentType = 'unknown'; if (headers.containsKey('Content-Type')) { - contentType = headers['Content-Type'] as String?; + contentType = headers['Content-Type']; } httpRequest diff --git a/packages/alice_objectbox/lib/alice_objectbox.dart b/packages/alice_objectbox/lib/alice_objectbox.dart index 2a46226a..23128bc4 100644 --- a/packages/alice_objectbox/lib/alice_objectbox.dart +++ b/packages/alice_objectbox/lib/alice_objectbox.dart @@ -1,6 +1,5 @@ import 'dart:math' show max; -import 'package:alice/core/alice_core.dart'; import 'package:alice/core/alice_storage.dart'; import 'package:alice/core/alice_utils.dart'; import 'package:alice/model/alice_http_call.dart'; @@ -94,21 +93,9 @@ class AliceObjectBox implements AliceStorage { } } - @override - void addHttpCall(AliceHttpCall aliceHttpCall) { - assert(aliceHttpCall.request != null, "Http call request can't be null"); - assert(aliceHttpCall.response != null, "Http call response can't be null"); - - _store.httpCalls.put(aliceHttpCall.toCached()); - } - @override Future removeCalls() => _store.httpCalls.removeAllAsync(); - @override - void subscribeToCallChanges(AliceOnCallsChanged callback) => - callsStream.listen(callback); - @override AliceStats getStats() => ( total: _store.httpCalls.count(), diff --git a/packages/alice_objectbox/lib/model/cached_alice_http_call.dart b/packages/alice_objectbox/lib/model/cached_alice_http_call.dart index 94c404f8..7616d7f4 100644 --- a/packages/alice_objectbox/lib/model/cached_alice_http_call.dart +++ b/packages/alice_objectbox/lib/model/cached_alice_http_call.dart @@ -1,3 +1,5 @@ +// ignore_for_file: must_be_immutable + import 'package:alice/model/alice_http_call.dart'; import 'package:alice/model/alice_http_error.dart'; import 'package:alice/model/alice_http_request.dart'; @@ -110,4 +112,24 @@ class CachedAliceHttpCall implements AliceHttpCall { this.response = response; loading = false; } + + @override + List get props => [ + id, + createdTime, + client, + loading, + secure, + method, + endpoint, + server, + uri, + duration, + request, + response, + error + ]; + + @override + bool? get stringify => true; } diff --git a/packages/alice_objectbox/lib/model/cached_alice_http_error.dart b/packages/alice_objectbox/lib/model/cached_alice_http_error.dart index 80e23a9a..079c206a 100644 --- a/packages/alice_objectbox/lib/model/cached_alice_http_error.dart +++ b/packages/alice_objectbox/lib/model/cached_alice_http_error.dart @@ -46,4 +46,10 @@ class CachedAliceHttpError implements AliceHttpError { /// Custom data type converter of [error]. set dbStackTrace(String? value) => stackTrace = value != null ? StackTrace.fromString(value) : null; + + @override + List get props => [error, stackTrace]; + + @override + bool? get stringify => true; } diff --git a/packages/alice_objectbox/lib/model/cached_alice_http_request.dart b/packages/alice_objectbox/lib/model/cached_alice_http_request.dart index 46aae34f..e39311bf 100644 --- a/packages/alice_objectbox/lib/model/cached_alice_http_request.dart +++ b/packages/alice_objectbox/lib/model/cached_alice_http_request.dart @@ -4,6 +4,7 @@ import 'dart:io' show Cookie; import 'package:alice/model/alice_form_data_file.dart'; import 'package:alice/model/alice_from_data_field.dart'; import 'package:alice/model/alice_http_request.dart'; +import 'package:alice/utils/alice_parser.dart'; import 'package:alice_objectbox/json_converter/alice_form_data_field_converter.dart'; import 'package:alice_objectbox/json_converter/alice_form_data_file_converter.dart'; import 'package:meta/meta.dart'; @@ -16,7 +17,7 @@ class CachedAliceHttpRequest implements AliceHttpRequest { this.objectId = 0, this.size = 0, DateTime? time, - this.headers = const {}, + this.headers = const {}, this.body = '', this.contentType = '', this.cookies = const [], @@ -39,15 +40,15 @@ class CachedAliceHttpRequest implements AliceHttpRequest { @override @Transient() - Map headers; + Map headers; /// Custom data type converter of [headers]. String get dbHeaders => jsonEncode(headers); /// Custom data type converter of [headers]. - set dbHeaders(String value) => - headers = jsonDecode(value) as Map; - + set dbHeaders(String value) => headers = AliceParser.parseHeaders( + headers: jsonDecode(value), + ); @override @Transient() dynamic body; @@ -132,4 +133,20 @@ class CachedAliceHttpRequest implements AliceHttpRequest { AliceFormDataFieldConverter.instance.fromJson(jsonDecode(field)), ) .toList(); + + @override + List get props => [ + size, + time, + headers, + body, + contentType, + cookies, + queryParameters, + formDataFiles, + formDataFields, + ]; + + @override + bool? get stringify => true; } diff --git a/packages/alice_objectbox/lib/model/cached_alice_http_response.dart b/packages/alice_objectbox/lib/model/cached_alice_http_response.dart index 7e7cc8ee..ac3f8c12 100644 --- a/packages/alice_objectbox/lib/model/cached_alice_http_response.dart +++ b/packages/alice_objectbox/lib/model/cached_alice_http_response.dart @@ -63,4 +63,16 @@ class CachedAliceHttpResponse implements AliceHttpResponse { (key, value) => MapEntry(key, value.toString()), ) : null; + + @override + List get props => [ + status, + size, + time, + body, + headers, + ]; + + @override + bool? get stringify => true; } diff --git a/pubspec.yaml b/pubspec.yaml index 1efb86c8..9447a089 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,3 @@ environment: sdk: ">=3.0.0 <4.0.0" dev_dependencies: melos: ^6.0.0 - -scripts: - analyze: - exec: flutter analyze --no-fatal-infos \ No newline at end of file