From 16d817c619a79519e7a44776ea02d94abbaed3f5 Mon Sep 17 00:00:00 2001 From: DatDang Date: Fri, 6 Dec 2024 10:07:53 +0700 Subject: [PATCH] TF-3267 Implement HTML attachment preview --- .../domain/exceptions/string_exception.dart | 7 ++ .../html_attachment_previewer.dart | 92 +++++++++++++++++++ core/lib/utils/string_convert.dart | 23 ++++- .../repository/email_repository_impl.dart | 10 ++ .../domain/repository/email_repository.dart | 4 + ...et_html_content_from_attachment_state.dart | 17 ++++ ...ml_content_from_attachment_interactor.dart | 82 +++++++++++++++++ .../presentation/bindings/email_bindings.dart | 3 + .../controller/single_email_controller.dart | 26 ++++++ .../extensions/attachment_extension.dart | 2 + .../extensions/media_type_extension.dart | 3 + .../single_email_controller_test.dart | 4 + 12 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 core/lib/domain/exceptions/string_exception.dart create mode 100644 core/lib/presentation/views/html_viewer/html_attachment_previewer.dart create mode 100644 lib/features/email/domain/state/get_html_content_from_attachment_state.dart create mode 100644 lib/features/email/domain/usecases/get_html_content_from_attachment_interactor.dart diff --git a/core/lib/domain/exceptions/string_exception.dart b/core/lib/domain/exceptions/string_exception.dart new file mode 100644 index 0000000000..e3d8d10ea1 --- /dev/null +++ b/core/lib/domain/exceptions/string_exception.dart @@ -0,0 +1,7 @@ +class UnsupportedCharsetException implements Exception { + const UnsupportedCharsetException(); +} + +class NullCharsetException implements Exception { + const NullCharsetException(); +} \ No newline at end of file diff --git a/core/lib/presentation/views/html_viewer/html_attachment_previewer.dart b/core/lib/presentation/views/html_viewer/html_attachment_previewer.dart new file mode 100644 index 0000000000..bb3d80c72f --- /dev/null +++ b/core/lib/presentation/views/html_viewer/html_attachment_previewer.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/utils/responsive_utils.dart'; +import 'package:core/presentation/utils/shims/dart_ui.dart'; +import 'package:core/presentation/views/button/tmail_button_widget.dart'; +import 'package:core/presentation/views/responsive/responsive_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pointer_interceptor/pointer_interceptor.dart'; +import 'package:universal_html/html.dart'; + +class HtmlAttachmentPreviewer extends StatefulWidget { + const HtmlAttachmentPreviewer({super.key, required this.htmlContent}); + + final String htmlContent; + + @override + State createState() => _HtmlAttachmentPreviewerState(); +} + +class _HtmlAttachmentPreviewerState extends State { + late final IFrameElement _iframeElement; + late final String _viewId; + + @override + void initState() { + super.initState(); + _iframeElement = IFrameElement() + ..srcdoc = widget.htmlContent + ..style.border = 'none' + ..style.overflow = 'hidden' + ..style.width = '100%' + ..style.height = '100%'; + _viewId = _getRandString(10); + + platformViewRegistry.registerViewFactory(_viewId, (int viewId) => _iframeElement); + } + + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Center( + child: ResponsiveWidget( + responsiveUtils: ResponsiveUtils(), + desktop: Container( + width: MediaQuery.sizeOf(context).width * 0.4, + color: Colors.white, + margin: const EdgeInsets.symmetric(vertical: 16), + child: HtmlElementView(viewType: _viewId), + ), + tablet: Container( + width: MediaQuery.sizeOf(context).width * 0.8, + color: Colors.white, + margin: const EdgeInsets.symmetric(vertical: 16), + child: HtmlElementView(viewType: _viewId), + ), + mobile: Container( + width: MediaQuery.sizeOf(context).width, + color: Colors.white, + margin: const EdgeInsets.symmetric(vertical: 16), + child: HtmlElementView(viewType: _viewId), + ), + ), + ), + Positioned( + top: 16, + right: 16, + child: PointerInterceptor( + child: TMailButtonWidget.fromIcon( + icon: ImagePaths().icClose, + iconSize: 40, + onTapActionCallback: () { + if (!mounted) return; + Get.back(); + }, + ), + ), + ), + ], + ); + } + + String _getRandString(int len) { + var random = Random.secure(); + var values = List.generate(len, (i) => random.nextInt(255)); + return base64UrlEncode(values); + } +} \ No newline at end of file diff --git a/core/lib/utils/string_convert.dart b/core/lib/utils/string_convert.dart index 3042a1c9b6..5ebfc26d9b 100644 --- a/core/lib/utils/string_convert.dart +++ b/core/lib/utils/string_convert.dart @@ -1,10 +1,29 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:core/domain/exceptions/string_exception.dart'; + class StringConvert { - static String? writeEmptyToNull(String text) { + static String? writeEmptyToNull(String text) { if (text.isEmpty) return null; return text; } - static String writeNullToEmpty(String? text) { + static String writeNullToEmpty(String? text) { return text ?? ''; } + + static String decodeFromBytes(Uint8List bytes, {required String? charset}) { + if (charset == null) { + throw const NullCharsetException(); + } else if (charset.toLowerCase().contains('utf-8')) { + return utf8.decode(bytes); + } else if (charset.toLowerCase().contains('latin')) { + return latin1.decode(bytes); + } else if (charset.toLowerCase().contains('ascii')) { + return ascii.decode(bytes); + } else { + throw const UnsupportedCharsetException(); + } + } } diff --git a/lib/features/email/data/repository/email_repository_impl.dart b/lib/features/email/data/repository/email_repository_impl.dart index 57860f7ef3..a86b77e54f 100644 --- a/lib/features/email/data/repository/email_repository_impl.dart +++ b/lib/features/email/data/repository/email_repository_impl.dart @@ -315,4 +315,14 @@ class EmailRepositoryImpl extends EmailRepository { emailId, eventActionType); } + + @override + Future sanitizeHtmlContent( + String htmlContent, + TransformConfiguration configuration + ) { + return _htmlDataSource.transformHtmlEmailContent( + htmlContent, + configuration); + } } \ No newline at end of file diff --git a/lib/features/email/domain/repository/email_repository.dart b/lib/features/email/domain/repository/email_repository.dart index 88ae516c27..922386969c 100644 --- a/lib/features/email/domain/repository/email_repository.dart +++ b/lib/features/email/domain/repository/email_repository.dart @@ -152,4 +152,8 @@ abstract class EmailRepository { AccountId accountId, EmailId emailId, EventActionType eventActionType); + + Future sanitizeHtmlContent( + String htmlContent, + TransformConfiguration configuration); } \ No newline at end of file diff --git a/lib/features/email/domain/state/get_html_content_from_attachment_state.dart b/lib/features/email/domain/state/get_html_content_from_attachment_state.dart new file mode 100644 index 0000000000..e3c2281ede --- /dev/null +++ b/lib/features/email/domain/state/get_html_content_from_attachment_state.dart @@ -0,0 +1,17 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; + +class GettingHtmlContentFromAttachment extends LoadingState {} + +class GetHtmlContentFromAttachmentSuccess extends UIState { + GetHtmlContentFromAttachmentSuccess({required this.sanitizedHtmlContent}); + + final String sanitizedHtmlContent; + + @override + List get props => [sanitizedHtmlContent]; +} + +class GetHtmlContentFromAttachmentFailure extends FeatureFailure { + GetHtmlContentFromAttachmentFailure({super.exception}); +} \ No newline at end of file diff --git a/lib/features/email/domain/usecases/get_html_content_from_attachment_interactor.dart b/lib/features/email/domain/usecases/get_html_content_from_attachment_interactor.dart new file mode 100644 index 0000000000..b8f167a0b9 --- /dev/null +++ b/lib/features/email/domain/usecases/get_html_content_from_attachment_interactor.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:core/presentation/utils/html_transformer/transform_configuration.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/string_convert.dart'; +import 'package:dartz/dartz.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:model/download/download_task_id.dart'; +import 'package:model/email/attachment.dart'; +import 'package:tmail_ui_user/features/email/domain/state/download_attachment_for_web_state.dart'; +import 'package:tmail_ui_user/features/email/domain/state/get_html_content_from_attachment_state.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/download_attachment_for_web_interactor.dart'; + +class GetHtmlContentFromAttachmentInteractor { + GetHtmlContentFromAttachmentInteractor(this._downloadAttachmentForWebInteractor); + + final DownloadAttachmentForWebInteractor _downloadAttachmentForWebInteractor; + + Stream> execute( + AccountId accountId, + Attachment attachment, + DownloadTaskId taskId, + String baseDownloadUrl, + TransformConfiguration transformConfiguration, + ) async* { + final onReceiveController = StreamController>(); + try { + yield Right(GettingHtmlContentFromAttachment()); + final downloadState = await _downloadAttachmentForWebInteractor.execute( + taskId, + attachment, + accountId, + baseDownloadUrl, + onReceiveController, + ).last; + + Either? sanitizeState; + await downloadState.fold( + (failure) { + sanitizeState = Left(GetHtmlContentFromAttachmentFailure( + exception: failure is FeatureFailure ? failure.exception : null, + )); + }, + (success) async { + if (success is! DownloadAttachmentForWebSuccess) { + sanitizeState = Right(GettingHtmlContentFromAttachment()); + } else { + final htmlContent = StringConvert.decodeFromBytes( + success.bytes, + charset: success.attachment.charset, + ); + try { + final sanitizedHtmlContent = await _downloadAttachmentForWebInteractor + .emailRepository + .sanitizeHtmlContent( + htmlContent, + transformConfiguration, + ); + sanitizeState = Right(GetHtmlContentFromAttachmentSuccess( + sanitizedHtmlContent: sanitizedHtmlContent, + )); + } catch (e) { + sanitizeState = Left(GetHtmlContentFromAttachmentFailure(exception: e)); + } + } + }, + ); + + onReceiveController.close(); + if (sanitizeState != null) { + yield sanitizeState!; + } + + } catch (e) { + logError('GetHtmlContentFromAttachmentInteractor:exception: $e'); + onReceiveController.close(); + yield Left(GetHtmlContentFromAttachmentFailure(exception: e)); + } + } +} \ No newline at end of file diff --git a/lib/features/email/presentation/bindings/email_bindings.dart b/lib/features/email/presentation/bindings/email_bindings.dart index a589ded969..413be48ad6 100644 --- a/lib/features/email/presentation/bindings/email_bindings.dart +++ b/lib/features/email/presentation/bindings/email_bindings.dart @@ -21,6 +21,7 @@ import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_email_read_ import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_star_email_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/move_to_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/print_email_interactor.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/get_html_content_from_attachment_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/store_event_attendance_status_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/store_opened_email_interactor.dart'; import 'package:tmail_ui_user/features/email/presentation/controller/email_supervisor_controller.dart'; @@ -70,6 +71,7 @@ class EmailBindings extends BaseBindings { Get.find(), Get.find(), Get.find(), + Get.find(), )); } @@ -152,6 +154,7 @@ class EmailBindings extends BaseBindings { Get.lazyPut(() => PrintEmailInteractor(Get.find())); Get.lazyPut(() => StoreEventAttendanceStatusInteractor(Get.find())); IdentityInteractorsBindings().dependencies(); + Get.lazyPut(() => GetHtmlContentFromAttachmentInteractor(Get.find())); } @override diff --git a/lib/features/email/presentation/controller/single_email_controller.dart b/lib/features/email/presentation/controller/single_email_controller.dart index 928c8e2dfe..dca8bca4fb 100644 --- a/lib/features/email/presentation/controller/single_email_controller.dart +++ b/lib/features/email/presentation/controller/single_email_controller.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:better_open_file/better_open_file.dart' as open_file; import 'package:core/core.dart'; +import 'package:core/presentation/views/html_viewer/html_attachment_previewer.dart'; import 'package:dartz/dartz.dart'; import 'package:dio/dio.dart'; import 'package:flutter/cupertino.dart'; @@ -50,6 +51,7 @@ import 'package:tmail_ui_user/features/email/domain/state/download_attachment_fo import 'package:tmail_ui_user/features/email/domain/state/download_attachments_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/export_attachment_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/get_email_content_state.dart'; +import 'package:tmail_ui_user/features/email/domain/state/get_html_content_from_attachment_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/mark_as_email_read_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/mark_as_email_star_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/move_to_mailbox_state.dart'; @@ -70,6 +72,7 @@ import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_star_email_ import 'package:tmail_ui_user/features/email/domain/usecases/move_to_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/parse_calendar_event_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/print_email_interactor.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/get_html_content_from_attachment_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/send_receipt_to_sender_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/store_event_attendance_status_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/store_opened_email_interactor.dart'; @@ -134,6 +137,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { final StoreOpenedEmailInteractor _storeOpenedEmailInteractor; final PrintEmailInteractor _printEmailInteractor; final StoreEventAttendanceStatusInteractor _storeEventAttendanceStatusInteractor; + final GetHtmlContentFromAttachmentInteractor _getHtmlContentFromAttachmentInteractor; CreateNewEmailRuleFilterInteractor? _createNewEmailRuleFilterInteractor; SendReceiptToSenderInteractor? _sendReceiptToSenderInteractor; @@ -181,6 +185,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { this._storeOpenedEmailInteractor, this._printEmailInteractor, this._storeEventAttendanceStatusInteractor, + this._getHtmlContentFromAttachmentInteractor, ); @override @@ -236,6 +241,10 @@ class SingleEmailController extends BaseController with AppLoaderMixin { calendarEventSuccess(success); } else if (success is StoreEventAttendanceStatusSuccess) { _showToastMessageEventAttendanceSuccess(success); + } else if (success is GetHtmlContentFromAttachmentSuccess) { + Get.dialog(HtmlAttachmentPreviewer( + htmlContent: success.sanitizedHtmlContent, + )); } } @@ -1816,6 +1825,23 @@ class SingleEmailController extends BaseController with AppLoaderMixin { if (PlatformInfo.isWeb) { if (PlatformInfo.isCanvasKit && attachment.validatePDFIcon()) { previewPDFFileAction(context, attachment); + } else if (attachment.validateHtmlAttachment()) { + final accountId = mailboxDashBoardController.accountId.value; + final downloadUrl = mailboxDashBoardController.sessionCurrent + ?.getDownloadUrl(jmapUrl: dynamicUrlInterceptors.jmapUrl); + final blobId = attachment.blobId; + + if (accountId == null || downloadUrl == null || blobId == null) return; + + consumeState(_getHtmlContentFromAttachmentInteractor.execute( + accountId, + attachment, + DownloadTaskId(blobId.value), + downloadUrl, + TransformConfiguration.fromTextTransformers( + TransformConfiguration.standardTextTransformers, + ), + )); } else { downloadAttachmentForWeb(attachment); } diff --git a/lib/features/email/presentation/extensions/attachment_extension.dart b/lib/features/email/presentation/extensions/attachment_extension.dart index 64fc09d289..99bdf34026 100644 --- a/lib/features/email/presentation/extensions/attachment_extension.dart +++ b/lib/features/email/presentation/extensions/attachment_extension.dart @@ -6,4 +6,6 @@ extension AttachmentExtension on Attachment { String getIcon(ImagePaths imagePaths) => type?.getIcon(imagePaths, fileName: name) ?? imagePaths.icFileEPup; bool validatePDFIcon() => type?.validatePDFIcon(fileName: name) ?? false; + + bool validateHtmlAttachment() => type?.validateHtmlMediaType(fileName: name) ?? false; } \ No newline at end of file diff --git a/lib/features/upload/domain/extensions/media_type_extension.dart b/lib/features/upload/domain/extensions/media_type_extension.dart index e3bfcc8d80..94c689c300 100644 --- a/lib/features/upload/domain/extensions/media_type_extension.dart +++ b/lib/features/upload/domain/extensions/media_type_extension.dart @@ -25,4 +25,7 @@ extension MediaTypeExtension on MediaType { bool validatePDFIcon({required String? fileName}) => mimeType == 'application/pdf' || (mimeType == 'application/octet-stream' && fileName?.endsWith('.pdf') == true); + + bool validateHtmlMediaType({required String? fileName}) => mimeType == 'text/html' || + (mimeType == 'application/octet-stream' && fileName?.endsWith('.html') == true); } diff --git a/test/features/email/presentation/controller/single_email_controller_test.dart b/test/features/email/presentation/controller/single_email_controller_test.dart index 1768bc9342..c1f1b8a32d 100644 --- a/test/features/email/presentation/controller/single_email_controller_test.dart +++ b/test/features/email/presentation/controller/single_email_controller_test.dart @@ -23,6 +23,7 @@ import 'package:tmail_ui_user/features/email/domain/model/event_action.dart'; import 'package:tmail_ui_user/features/email/domain/state/calendar_event_accept_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/parse_calendar_event_state.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/calendar_event_accept_interactor.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/get_html_content_from_attachment_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/maybe_calendar_event_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/calendar_event_reject_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/download_attachment_for_web_interactor.dart'; @@ -91,6 +92,7 @@ const fallbackGenerators = { MockSpec(), MockSpec(), MockSpec(), + MockSpec(), ]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -125,6 +127,7 @@ void main() { final printUtils = MockPrintUtils(); final applicationManager = MockApplicationManager(); final mockToastManager = MockToastManager(); + final getHtmlContentFromAttachmentInteractor = MockGetHtmlContentFromAttachmentInteractor(); late SingleEmailController singleEmailController; @@ -179,6 +182,7 @@ void main() { storeOpenedEmailInteractor, printEmailInteractor, storeEventAttendanceStatusInteractor, + getHtmlContentFromAttachmentInteractor, ); });