Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TF-3514 Detect base64 image to transfer it to cid attachment #3531

26 changes: 26 additions & 0 deletions core/lib/utils/string_convert.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:typed_data';

import 'package:core/utils/app_logger.dart';
import 'package:core/domain/exceptions/string_exception.dart';
import 'package:http_parser/http_parser.dart';

class StringConvert {
static const String separatorPattern = r'[ ,;]+';
Expand Down Expand Up @@ -73,4 +74,29 @@ class StringConvert {
static String toUrlScheme(String hostScheme) {
return '$hostScheme://';
}

static Uint8List convertBase64ImageTagToBytes(String base64ImageTag) {
if (!base64ImageTag.contains('base64,')) {
throw ArgumentError('The string is not valid Base64 data from an <img> tag.');
}

final base64Data = base64ImageTag.split(',').last;

return base64Decode(base64Data);
}

static MediaType? getMediaTypeFromBase64ImageTag(String base64ImageTag) {
try {
if (!base64ImageTag.startsWith("data:") || !base64ImageTag.contains(";base64,")) {
return null;
}

final mimeType = base64ImageTag.split(";")[0].split(":")[1];
log('StringConvert::getMediaTypeFromBase64ImageTag:mimeType = $mimeType');
return MediaType.parse(mimeType);
} catch (e) {
logError('StringConvert::getMimeTypeFromBase64ImageTag:Exception = $e');
return null;
}
}
}
47 changes: 47 additions & 0 deletions core/test/utils/string_convert_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -283,4 +283,51 @@ void main() {
);
});
});

group('StringConvert::getMediaTypeFromBase64ImageTag::', () {
test('should return correct MediaType for valid JPEG base64 tag', () {
const validJpegTag = 'data:image/jpeg;base64,/9j/4AAQSkZJRg==';
final result = StringConvert.getMediaTypeFromBase64ImageTag(validJpegTag);
expect(result, isNotNull);
expect(result!.type, 'image');
expect(result.subtype, 'jpeg');
});

test('should return correct MediaType for valid PNG base64 tag', () {
const validPngTag = 'data:image/png;base64,iVBORw0KGgo===';
final result = StringConvert.getMediaTypeFromBase64ImageTag(validPngTag);
expect(result, isNotNull);
expect(result!.type, 'image');
expect(result.subtype, 'png');
});

test('should return null for string not starting with "data:"', () {
const invalidTag = 'image/jpeg;base64,/9j/4AAQSkZJRg==';
final result = StringConvert.getMediaTypeFromBase64ImageTag(invalidTag);
expect(result, isNull);
});

test('should return null for string without ";base64,"', () {
const invalidTag = 'data:image/jpeg,/9j/4AAQSkZJRg==';
final result = StringConvert.getMediaTypeFromBase64ImageTag(invalidTag);
expect(result, isNull);
});

test('should return null for invalid format', () {
const invalidTag = 'data:invalid;base64,data';
final result = StringConvert.getMediaTypeFromBase64ImageTag(invalidTag);
expect(result, isNull);
});

test('should return null for empty string', () {
const emptyTag = '';
final result = StringConvert.getMediaTypeFromBase64ImageTag(emptyTag);
expect(result, isNull);
});

test('should handle null input gracefully', () {
const String? nullTag = null;
expect(() => StringConvert.getMediaTypeFromBase64ImageTag(nullTag!), throwsA(isA<TypeError>()));
});
});
}
9 changes: 9 additions & 0 deletions integration_test/robots/email_robot.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tmail_ui_user/features/email/presentation/widgets/email_view_back_button.dart';
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';

import '../base/core_robot.dart';
Expand All @@ -15,4 +16,12 @@ class EmailRobot extends CoreRobot {
await $(AppLocalizations().downloadAll).tap();
await $.pumpAndSettle();
}

Future<void> onTapReplyEmail() async {
await $(#reply_email_button).tap();
}

Future<void> onTapBackButton() async {
await $(find.byType(EmailViewBackButton)).first.tap();
}
}
5 changes: 4 additions & 1 deletion integration_test/robots/search_robot.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ class SearchRobot extends CoreRobot {
}

Future<void> openEmailWithSubject(String subject) async {
await $(find.byType(EmailTileBuilder)).first.tap();
await $(find.byType(EmailTileBuilder))
.which<EmailTileBuilder>((view) => view.presentationEmail.subject == subject)
.first
.tap();
await $.pump(const Duration(seconds: 2));
}
}
5 changes: 4 additions & 1 deletion integration_test/robots/thread_robot.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ class ThreadRobot extends CoreRobot {
}

Future<void> openEmailWithSubject(String subject) async {
await $(find.byType(EmailTileBuilder)).first.tap();
await $(find.byType(EmailTileBuilder))
.which<EmailTileBuilder>((view) => view.presentationEmail.subject == subject)
.first
.tap();
await $.pump(const Duration(seconds: 2));
}

Expand Down
19 changes: 10 additions & 9 deletions integration_test/scenarios/no_disposition_inline_scenario.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:core/presentation/views/html_viewer/html_content_viewer_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tmail_ui_user/features/email/presentation/email_view.dart';
import 'package:tmail_ui_user/features/search/email/presentation/search_email_view.dart';
Expand All @@ -8,13 +9,12 @@ import '../robots/search_robot.dart';
import '../robots/thread_robot.dart';

class NoDispositionInlineScenario extends BaseTestScenario {
static const _firstPartOfBase64DataString = 'data:image/jpeg;base64';
static const emailSubject = 'Greeting Card';

const NoDispositionInlineScenario(super.$);

@override
Future<void> runTestLogic() async {
const emailSubject = 'Greeting Card';

final threadRobot = ThreadRobot($);
final searchRobot = SearchRobot($);

Expand All @@ -27,7 +27,7 @@ class NoDispositionInlineScenario extends BaseTestScenario {

await searchRobot.openEmailWithSubject(emailSubject);
await _expectEmailViewVisible();
await _expectHtmlContentViewerVisible();
await _ensureHtmlContentViewerVisible();
await _expectEmailViewWithBase64Image();
}

Expand All @@ -43,16 +43,17 @@ class NoDispositionInlineScenario extends BaseTestScenario {
await expectViewVisible($(EmailView));
}

Future<void> _expectHtmlContentViewerVisible() async {
await expectViewVisible($(HtmlContentViewer));
Future<void> _ensureHtmlContentViewerVisible() async {
await $(HtmlContentViewer).scrollTo(scrollDirection: AxisDirection.down);
}

Future<void> _expectEmailViewWithBase64Image() async {
expect(
$(EmailView)
.$(HtmlContentViewer)
$(HtmlContentViewer)
.which<HtmlContentViewer>((view) {
return view.contentHtml.contains(_firstPartOfBase64DataString);
final contentHtml = view.contentHtml;

return contentHtml.contains('data:image/') && contentHtml.contains(';base64');
}),
findsOneWidget,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/views/html_viewer/html_content_viewer_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:model/email/prefix_email_address.dart';
import 'package:tmail_ui_user/features/composer/presentation/composer_view.dart';
import 'package:tmail_ui_user/features/email/presentation/email_view.dart';
import 'package:tmail_ui_user/features/search/email/presentation/search_email_view.dart';
import 'package:tmail_ui_user/features/thread/presentation/widgets/email_tile_builder.dart';
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';

import '../base/base_test_scenario.dart';
import '../robots/composer_robot.dart';
import '../robots/email_robot.dart';
import '../robots/search_robot.dart';
import '../robots/thread_robot.dart';

class ReplyEmailWithContentContainImageBase64DataScenario extends BaseTestScenario {
const ReplyEmailWithContentContainImageBase64DataScenario(super.$);

@override
Future<void> runTestLogic() async {
const emailSubject = 'Mail with base64';
const emailUser = String.fromEnvironment('BASIC_AUTH_EMAIL');

final threadRobot = ThreadRobot($);
final searchRobot = SearchRobot($);
final emailRobot = EmailRobot($);
final composerRobot = ComposerRobot($);
final imagePaths = ImagePaths();
final appLocalizations = AppLocalizations();

await threadRobot.openSearchView();
await _expectSearchViewVisible();

await searchRobot.enterQueryString(emailSubject);
await searchRobot.tapOnShowAllResultsText();
await _expectSearchResultEmailListVisible();

await searchRobot.openEmailWithSubject(emailSubject);
await _expectEmailViewVisible();
await _expectReplyEmailButtonVisible();

await emailRobot.onTapReplyEmail();
await _expectComposerViewVisible();

await composerRobot.grantContactPermission();

await composerRobot.addRecipientIntoField(
prefixEmailAddress: PrefixEmailAddress.to,
email: emailUser,
);

await composerRobot.sendEmail(imagePaths);
await _expectSendEmailSuccessToast(appLocalizations);
await Future.delayed(const Duration(seconds: 3));

await emailRobot.onTapBackButton();
await $.pumpAndSettle(duration: const Duration(seconds: 3));
await _expectEmailCidWithSubject(emailSubject);

await threadRobot.openEmailWithSubject(
'${appLocalizations.prefix_reply_email} $emailSubject'
);
await _expectEmailViewVisible();
await Future.delayed(const Duration(seconds: 3));
await _ensureHtmlContentViewerVisible();
await _expectEmailViewWithCidImage();
}

Future<void> _expectSearchViewVisible() async {
await expectViewVisible($(SearchEmailView));
}

Future<void> _expectSearchResultEmailListVisible() async {
await expectViewVisible($(#search_email_list_notification_listener));
}

Future<void> _expectEmailViewVisible() async {
await expectViewVisible($(EmailView));
}

Future<void> _expectReplyEmailButtonVisible() async {
await expectViewVisible($(#reply_email_button));
}

Future<void> _expectComposerViewVisible() async {
await expectViewVisible($(ComposerView));
}

Future<void> _expectSendEmailSuccessToast(AppLocalizations appLocalizations) async {
await expectViewVisible(
$(find.text(appLocalizations.message_has_been_sent_successfully)),
);
}

Future<void> _expectEmailCidWithSubject(String subject) => expectViewVisible(
$(EmailTileBuilder)
.which<EmailTileBuilder>(
(widget) => widget.presentationEmail.subject?.contains(subject) == true
),
);

Future<void> _ensureHtmlContentViewerVisible() async {
await $(HtmlContentViewer).scrollTo(scrollDirection: AxisDirection.down);
}

Future<void> _expectEmailViewWithCidImage() async {
HtmlContentViewer? htmlContentViewer;

await $(HtmlContentViewer)
.which<HtmlContentViewer>((view) {
htmlContentViewer = view;
return true;
})
.first
.tap();

final contentHtml = htmlContentViewer!.contentHtml;
final cidCount = RegExp(r'cid').allMatches(contentHtml).length;
expect(cidCount, 2);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import '../../base/test_base.dart';
import '../../scenarios/reply_email_with_content_contain_image_base64_data_scenario.dart';

void main() {
TestBase().runPatrolTest(
description: 'Should see inline image with cid when reply email with content contain image base64 data',
scenarioBuilder: ($) => ReplyEmailWithContentContainImageBase64DataScenario($),
);
}
26 changes: 15 additions & 11 deletions lib/features/composer/data/repository/composer_repository_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,14 @@ class ComposerRepositoryImpl extends ComposerRepository {
String emailContent = createEmailRequest.emailContent;
Set<EmailBodyPart> emailAttachments = Set.from(createEmailRequest.createAttachments());

if (createEmailRequest.inlineAttachments?.isNotEmpty == true) {
final tupleContentInlineAttachments = await _replaceImageBase64ToImageCID(
emailContent: emailContent,
inlineAttachments: createEmailRequest.inlineAttachments!
);
final tupleContentInlineAttachments = await _replaceImageBase64ToImageCID(
emailContent: emailContent,
inlineAttachments: createEmailRequest.inlineAttachments ?? {},
uploadUri: createEmailRequest.uploadUri,
);

emailContent = tupleContentInlineAttachments.value1;
emailAttachments.addAll(tupleContentInlineAttachments.value2);
}
emailContent = tupleContentInlineAttachments.value1;
emailAttachments.addAll(tupleContentInlineAttachments.value2);

emailContent = await _removeCollapsedExpandedSignatureEffect(emailContent: emailContent);

Expand All @@ -83,18 +82,23 @@ class ComposerRepositoryImpl extends ComposerRepository {

Future<Tuple2<String, Set<EmailBodyPart>>> _replaceImageBase64ToImageCID({
required String emailContent,
required Map<String, Attachment> inlineAttachments
required Map<String, Attachment> inlineAttachments,
required Uri? uploadUri,
}) {
try {
return _htmlDataSource.replaceImageBase64ToImageCID(
emailContent: emailContent,
inlineAttachments: inlineAttachments);
inlineAttachments: inlineAttachments,
uploadUri: uploadUri,
);
} catch (e) {
logError('ComposerRepositoryImpl::_replaceImageBase64ToImageCID: Exception: $e');
return Future.value(
Tuple2(
emailContent,
inlineAttachments.values.toList().toEmailBodyPart(charset: Constant.base64Charset)
inlineAttachments.isNotEmpty
? inlineAttachments.values.toList().toEmailBodyPart(charset: Constant.base64Charset)
: {},
)
);
}
Expand Down
Loading
Loading