TF-4265 Add integration test for attachment reminder on mobile

This commit is contained in:
dab246
2026-01-30 16:09:38 +07:00
committed by Dat H. Pham
parent 74c0c75023
commit 2a3325ab7a
17 changed files with 781 additions and 899 deletions
@@ -0,0 +1,52 @@
import 'package:core/utils/app_logger.dart';
import 'package:core/utils/config/app_config_loader.dart';
import 'package:flutter/foundation.dart';
import 'package:tmail_ui_user/features/composer/presentation/manager/attachment_keywords_configuration_parser.dart';
import 'package:tmail_ui_user/features/composer/presentation/manager/attachment_text_detector.dart';
import 'package:tmail_ui_user/features/composer/presentation/model/attachment_keyword_config.dart';
import 'package:tmail_ui_user/main/utils/app_config.dart';
class AttachmentKeywordConfigManager {
static final AttachmentKeywordConfigManager _instance = AttachmentKeywordConfigManager._();
factory AttachmentKeywordConfigManager() => _instance;
AttachmentKeywordConfigManager._();
AttachmentKeywordConfig? _cachedConfig;
AppConfigLoader? _appConfigLoader;
static const String _configPath =
AppConfig.attachmentKeywordsConfigurationPath;
@visibleForTesting
void injectLoader(AppConfigLoader loader) {
_appConfigLoader = loader;
}
Future<AttachmentKeywordConfig> getConfig() async {
if (_cachedConfig != null) {
return _cachedConfig!;
}
try {
_appConfigLoader ??= AppConfigLoader();
_cachedConfig = await _appConfigLoader?.load<AttachmentKeywordConfig>(
_configPath,
AttachmentKeywordsConfigurationParser(),
);
} catch (e) {
logWarning(
"AttachmentKeywordConfigManager::getConfig:Error loading keyword config: $e");
_cachedConfig = AttachmentKeywordConfig();
}
return _cachedConfig!;
}
void clearCache() {
_cachedConfig = null;
_appConfigLoader = null;
AttachmentTextDetector.clearPatternCache();
}
}
@@ -4,15 +4,15 @@ import 'dart:typed_data';
import 'package:core/utils/app_logger.dart';
import 'package:core/utils/config/app_config_parser.dart';
import 'package:tmail_ui_user/features/composer/presentation/model/keyword_config.dart';
import 'package:tmail_ui_user/features/composer/presentation/model/attachment_keyword_config.dart';
class AttachmentKeywordsConfigurationParser
extends AppConfigParser<KeywordConfig> {
extends AppConfigParser<AttachmentKeywordConfig> {
@override
Future<KeywordConfig> parse(String value) async {
Future<AttachmentKeywordConfig> parse(String value) async {
try {
final jsonObject = jsonDecode(value);
return KeywordConfig.fromJson(jsonObject);
return AttachmentKeywordConfig.fromJson(jsonObject);
} catch (e) {
logWarning('AttachmentKeywordsConfigurationParser::parse(): $e');
rethrow;
@@ -20,7 +20,7 @@ class AttachmentKeywordsConfigurationParser
}
@override
Future<KeywordConfig> parseData(ByteData data) {
Future<AttachmentKeywordConfig> parseData(ByteData data) {
throw UnimplementedError();
}
}
@@ -52,7 +52,10 @@ class AttachmentTextDetector {
/// Cached RegExp Pattern for the default case (when no include list is provided).
static String? _cachedDefaultPattern;
/// Threshold to switch from Sync to Async execution.
/// Cached RegExp Pattern for the case when a custom include list is provided.
static List<String>? _cachedIncludeList;
static String? _cachedIncludePattern;
/// Threshold to switch from Sync to Async execution on native platforms.
///
/// Value: 20,000 characters (approx. 4-5 pages of text).
///
@@ -64,57 +67,14 @@ class AttachmentTextDetector {
/// - For > 20k chars, Regex processing might exceed 16ms on low-end devices, causing jank.
///
/// -> Below 20k: Run Sync (Instant response).
/// -> Above 20k: Run Async (Safety for UI).
/// -> Above 20k: Run Async via `compute` (offloads to a separate Isolate on native).
///
/// **Web limitation:** Flutter Web does not support true Dart isolates. On web, `compute()`
/// runs on the same main thread, so the async branch does NOT provide UI isolation.
/// Long drafts (> 20k chars) may still block the event loop on web. A proper web
/// strategy (e.g., Service Worker or chunked async processing) is not yet implemented.
static const int _kAsyncExecutionThreshold = 20000;
/// Detect if the text contains keywords suggesting there is an attachment.
/// [lang] is the language code (`en`, `fr`, `ru`, `vi`, `ar`, ...).
static bool containsAttachmentKeyword(String text, {required String lang}) {
final lowerText = text.toLowerCase();
final keywords = _keywordsByLang[lang.toLowerCase()];
if (keywords == null) return false;
return keywords.any((k) => lowerText.contains(k.toLowerCase()));
}
/// Returns a list of matched keywords (if any) for the specified language
static List<String> matchedKeywords(String text, {required String lang}) {
final lowerText = text.toLowerCase();
final keywords = _keywordsByLang[lang.toLowerCase()];
if (keywords == null) return [];
return keywords
.where((k) => lowerText.contains(k.toLowerCase()))
.toList();
}
/// Detect if text contains keyword in any language
static bool containsAnyAttachmentKeyword(String text) {
final lowerText = text.toLowerCase();
for (final keywords in _keywordsByLang.values) {
if (keywords.any((k) => lowerText.contains(k.toLowerCase()))) {
return true;
}
}
return false;
}
/// Returns a map of languages with a list of matched keywords
static Map<String, List<String>> matchedKeywordsAll(String text) {
final result = <String, List<String>>{};
final lowerText = text.toLowerCase();
_keywordsByLang.forEach((lang, keywords) {
final matches =
keywords.where((k) => lowerText.contains(k.toLowerCase())).toList();
if (matches.isNotEmpty) {
result[lang] = matches;
}
});
return result;
}
/// Builds the Regex pattern.
/// Merges [defaultKeywords] with [includeList] to create the search scope.
static String _generatePatternString(List<String> additionalKeywords) {
@@ -132,23 +92,40 @@ class AttachmentTextDetector {
// (?![\p{L}]) ensures we don't match substrings inside other words (e.g., "filetage").
final pattern = allKeywords.map(RegExp.escape).join('|');
return'($pattern)(?![\\p{L}])';
// (?<![\p{L}]) ensures no letter precedes the keyword
// (?![\p{L}]) ensures no letter follows the keyword
return '(?<![\\p{L}])($pattern)(?![\\p{L}])';
}
static String _getPattern(List<String> includeList) {
if (includeList.isNotEmpty) {
return _generatePatternString(includeList);
if (includeList.isEmpty) {
_cachedDefaultPattern ??= _generatePatternString([]);
return _cachedDefaultPattern!;
}
_cachedDefaultPattern ??= _generatePatternString([]);
return _cachedDefaultPattern!;
final cached = _cachedIncludeList;
if (cached != null &&
cached.length == includeList.length &&
cached.every(includeList.contains)) {
return _cachedIncludePattern!;
}
_cachedIncludeList = List.unmodifiable(includeList);
_cachedIncludePattern = _generatePatternString(includeList);
return _cachedIncludePattern!;
}
static bool _passesAllFilters(
String text, Match match, List<KeywordFilter> filters) {
return filters.every((filter) => filter.isValid(text, match));
}
/// The core logic function.
/// 1. Builds/Retrieves the Regex.
/// 2. Scans the text.
/// 3. Filters results using Exclude List.
static List<String> _processInIsolate(DetectionParams params) {
/// Can be executed on the main thread (sync path) or inside an Isolate (async path).
static List<String> _detectKeywords(DetectionParams params) {
final text = params.text;
if (text.isEmpty) return [];
@@ -158,27 +135,20 @@ class AttachmentTextDetector {
caseSensitive: false,
);
final matches = regex.allMatches(text);
final result = <String>{};
return regex
.allMatches(text)
.where((match) => _passesAllFilters(text, match, params.filters))
.map((match) => match.group(0)!.toLowerCase())
.toSet()
.toList();
}
for (final match in matches) {
final keyword = match.group(0)!.toLowerCase();
// Check if the found keyword should be excluded based on context.
bool isAccepted = true;
for (final filter in params.filters) {
if (!filter.isValid(text, match)) {
isAccepted = false;
break;
}
}
if (isAccepted) {
result.add(keyword);
}
}
return result.toList();
/// Clears all cached regex patterns.
/// Call this when the keyword configuration changes (e.g., on logout or config reload).
static void clearPatternCache() {
_cachedDefaultPattern = null;
_cachedIncludeList = null;
_cachedIncludePattern = null;
}
/// Detects keywords in the text.
@@ -206,11 +176,12 @@ class AttachmentTextDetector {
filters: filters,
);
// Decision: Run Sync (fast) or Async (safe)
// On native: below threshold → sync (faster), above → offload to Isolate via compute().
// On web: compute() does not create a real Isolate; both paths run on the main thread.
if (forceSync || text.length < _kAsyncExecutionThreshold) {
return _processInIsolate(params);
return _detectKeywords(params);
} else {
return await compute(_processInIsolate, params);
return await compute(_detectKeywords, params);
}
}
}
@@ -8,6 +8,9 @@ class ExcludeListFilter with TokenExtractionMixin implements KeywordFilter {
ExcludeListFilter(List<String> rawExcludes)
: _excludes = rawExcludes.map((e) => e.toLowerCase()).toSet();
static final _trailingPunctRegex = RegExp(r'[^\w\s]+$');
static final _leadingPunctRegex = RegExp(r'^[^\w\s]+');
@override
bool isValid(String fullText, Match match) {
if (_excludes.isEmpty) return true;
@@ -20,11 +23,11 @@ class ExcludeListFilter with TokenExtractionMixin implements KeywordFilter {
if (_excludes.contains(lowerToken)) return false;
// Check 2: Block even if it has trailing punctuation (e.g., "file-246.")
final cleanToken = lowerToken.replaceAll(RegExp(r'[^\w\s]+$'), '');
final cleanToken = lowerToken.replaceAll(_trailingPunctRegex, '');
if (_excludes.contains(cleanToken)) return false;
// Check 3: Block even if it has leading punctuation (e.g., "(file")
final fullyCleanToken = cleanToken.replaceAll(RegExp(r'^[^\w\s]+'), '');
final fullyCleanToken = cleanToken.replaceAll(_leadingPunctRegex, '');
if (_excludes.contains(fullyCleanToken)) return false;
return true;
@@ -1,43 +0,0 @@
import 'package:core/utils/app_logger.dart';
import 'package:core/utils/config/app_config_loader.dart';
import 'package:tmail_ui_user/features/composer/presentation/manager/attachment_keywords_configuration_parser.dart';
import 'package:tmail_ui_user/features/composer/presentation/model/keyword_config.dart';
import 'package:tmail_ui_user/main/utils/app_config.dart';
class KeywordConfigManager {
static final KeywordConfigManager _instance = KeywordConfigManager._();
factory KeywordConfigManager() => _instance;
KeywordConfigManager._();
KeywordConfig? _cachedConfig;
AppConfigLoader? _appConfigLoader;
static const String _configPath =
AppConfig.attachmentKeywordsConfigurationPath;
Future<KeywordConfig> getConfig() async {
if (_cachedConfig != null) {
return _cachedConfig!;
}
try {
_appConfigLoader ??= AppConfigLoader();
_cachedConfig = await _appConfigLoader?.load<KeywordConfig>(
_configPath,
AttachmentKeywordsConfigurationParser(),
);
} catch (e) {
logWarning(
"KeywordConfigManager::getConfig:Error loading keyword config: $e");
_cachedConfig = const KeywordConfig();
}
return _cachedConfig!;
}
void clearCache() {
_cachedConfig = null;
}
}