TF-4265 Add exclude list support to AttachmentTextDetector
This commit is contained in:
@@ -886,7 +886,7 @@ class ComposerController extends BaseController
|
||||
_sendButtonState = ButtonState.enabled;
|
||||
return;
|
||||
}
|
||||
final attachmentKeywords = validateAttachmentReminder(
|
||||
final attachmentKeywords = await validateAttachmentReminder(
|
||||
emailSubject: subjectEmail.value ?? '',
|
||||
emailContent: emailContent,
|
||||
);
|
||||
|
||||
@@ -9,14 +9,15 @@ import 'package:tmail_ui_user/main/routes/route_navigation.dart';
|
||||
|
||||
extension AttachmentDetectionExtension on ComposerController {
|
||||
|
||||
List<String> validateAttachmentReminder({
|
||||
Future<List<String>> validateAttachmentReminder({
|
||||
required String emailContent,
|
||||
required String emailSubject,
|
||||
}) {
|
||||
}) async {
|
||||
try {
|
||||
final fullContent = '$emailSubject $emailContent';
|
||||
final plainText = HtmlUtils.extractPlainText(fullContent);
|
||||
final keywords = AttachmentTextDetector.matchedKeywordsUnique(plainText);
|
||||
final keywords =
|
||||
await AttachmentTextDetector.matchedKeywordsUnique(plainText);
|
||||
if (keywords.isEmpty) {
|
||||
return [];
|
||||
} else {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/manager/detection_params.dart';
|
||||
import 'package:tmail_ui_user/features/composer/presentation/manager/keyword_filter.dart';
|
||||
import 'package:tmail_ui_user/main/localizations/language_code_constants.dart';
|
||||
|
||||
class AttachmentTextDetector {
|
||||
@@ -47,6 +50,22 @@ class AttachmentTextDetector {
|
||||
],
|
||||
};
|
||||
|
||||
static RegExp? _combinedRegExp;
|
||||
/// SYNC/ASYNC EXECUTION THRESHOLD
|
||||
///
|
||||
/// Value: 20,000 characters (approx. 4-5 pages of text).
|
||||
///
|
||||
/// Technical Rationale:
|
||||
/// 1. Frame Budget (16ms): To maintain 60fps, operations on the UI thread must stay under 16ms.
|
||||
/// 2. Isolate Overhead: `compute` takes about 2-4ms just to copy data and start the thread.
|
||||
/// 3. Real-world Benchmark:
|
||||
/// - For < 20k chars, Sync execution is usually faster (< 10ms) than the Async overhead.
|
||||
/// - 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).
|
||||
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}) {
|
||||
@@ -95,48 +114,85 @@ class AttachmentTextDetector {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Store a single combined RegExp pattern
|
||||
static RegExp? _combinedRegExp;
|
||||
|
||||
/// Initializes a single optimized RegExp that contains all keywords.
|
||||
/// Initializes the single optimized RegExp pattern.
|
||||
static void _initializeRegExp() {
|
||||
if (_combinedRegExp != null) return;
|
||||
|
||||
// Flatten all keywords from all languages into a single list
|
||||
final allKeywords = _keywordsByLang.values.expand((list) => list).toSet().toList();
|
||||
final allKeywords =
|
||||
_keywordsByLang.values.expand((l) => l).toSet().toList();
|
||||
|
||||
// Sort keywords by length descending (longest first).
|
||||
// Sort by length descending (Longest first).
|
||||
// Crucial to ensure "attachment" is matched before "attach".
|
||||
allKeywords.sort((a, b) => b.length.compareTo(a.length));
|
||||
|
||||
// Create a pattern like: (attachment|attach|file|files)(?![\p{L}])
|
||||
// Join all keywords with OR operator (|)
|
||||
final patternString = allKeywords.map(RegExp.escape).join('|');
|
||||
// LOGIC:
|
||||
// 1. RegExp.escape: Neutralizes special characters.
|
||||
// 2. No Nested Quantifiers: Prevents catastrophic backtracking.
|
||||
// 3. Linear Scan: Uses logical OR (|) for a single-pass scan.
|
||||
final pattern = allKeywords.map(RegExp.escape).join('|');
|
||||
|
||||
// Pattern: (keyword1|keyword2)(?![\p{L}])
|
||||
// (?![\p{L}]): Negative lookahead to ensure the next char is NOT a letter.
|
||||
// Allows: numbers (file123), punctuation (file.), spaces (file ).
|
||||
// Rejects: letter extensions (filetage).
|
||||
_combinedRegExp = RegExp(
|
||||
'($patternString)(?![\\p{L}])',
|
||||
'($pattern)(?![\\p{L}])',
|
||||
unicode: true,
|
||||
caseSensitive: false, // Let Regex handle case-insensitivity
|
||||
caseSensitive: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Detects unique keywords in the provided text based on predefined language lists.
|
||||
static List<String> matchedKeywordsUnique(String text) {
|
||||
if (text.isEmpty) return [];
|
||||
|
||||
// Ensure RegExp are initialized
|
||||
static List<String> _processMatchedKeywordsInIsolate(DetectionParams params) {
|
||||
// Re-initialize RegExp (Isolates do not share static memory with the main app)
|
||||
_initializeRegExp();
|
||||
|
||||
final text = params.text;
|
||||
final filters = params.filters;
|
||||
|
||||
if (text.isEmpty) return [];
|
||||
|
||||
final matches = _combinedRegExp!.allMatches(text);
|
||||
final result = <String>{};
|
||||
|
||||
for (final match in matches) {
|
||||
// match.group(0) returns the actual text found (e.g., "FILE", "File").
|
||||
// We convert it to lowercase to normalize the result list.
|
||||
if (match.group(0) != null) {
|
||||
result.add(match.group(0)!.toLowerCase());
|
||||
final keyword = match.group(0)!.toLowerCase();
|
||||
|
||||
// Apply Filter Pipeline
|
||||
bool isAccepted = true;
|
||||
for (final filter in filters) {
|
||||
if (!filter.isValid(text, match)) {
|
||||
isAccepted = false;
|
||||
break; // Stop checking other filters to save CPU
|
||||
}
|
||||
}
|
||||
|
||||
if (isAccepted) {
|
||||
result.add(keyword);
|
||||
}
|
||||
}
|
||||
|
||||
return result.toList();
|
||||
}
|
||||
|
||||
/// Smart keyword detection (Hybrid Sync/Async).
|
||||
/// Automatically decides whether to run on the UI Thread or a Background Thread.
|
||||
/// [forceSync]: If true, it will be MANDATORY to run on the UI Thread (ignoring the threshold).
|
||||
/// Use this parameter for unit tests to benchmark the algorithm more accurately.
|
||||
static Future<List<String>> matchedKeywordsUnique(
|
||||
String text, {
|
||||
List<KeywordFilter> filters = const [],
|
||||
bool forceSync = false,
|
||||
}) async {
|
||||
// If text is short, run Synchronously to avoid Isolate overhead cost.
|
||||
if (forceSync || text.length < _kAsyncExecutionThreshold) {
|
||||
return _processMatchedKeywordsInIsolate(
|
||||
DetectionParams(text: text, filters: filters),
|
||||
);
|
||||
} else { // If text is long, use `compute` to offload to an Isolate, preventing UI freeze.
|
||||
return await compute(
|
||||
_processMatchedKeywordsInIsolate,
|
||||
DetectionParams(text: text, filters: filters),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:tmail_ui_user/features/composer/presentation/manager/keyword_filter.dart';
|
||||
|
||||
/// Data Transfer Object used to package data sent to the Isolate.
|
||||
/// Since Isolates cannot share memory, we need a simple object to pass parameters.
|
||||
class DetectionParams {
|
||||
final String text;
|
||||
final List<KeywordFilter> filters;
|
||||
|
||||
DetectionParams({required this.text, required this.filters});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:tmail_ui_user/features/composer/presentation/manager/keyword_filter.dart';
|
||||
|
||||
/// Implementation of Exclude List Filter.
|
||||
/// Optimized for memory usage by avoiding unnecessary String allocations.
|
||||
class ExcludeListFilter implements KeywordFilter {
|
||||
final Set<String> _excludes;
|
||||
|
||||
/// Constructor takes a List for convenience, but converts to Set for O(1) lookup.
|
||||
ExcludeListFilter(List<String> rawExcludes)
|
||||
: _excludes = rawExcludes.map((e) => e.toLowerCase()).toSet();
|
||||
|
||||
/// Static RegExp to check for whitespace.
|
||||
/// Using a static instance avoids re-compilation in loops.
|
||||
static final RegExp _whitespaceRegExp = RegExp(r'\s');
|
||||
|
||||
@override
|
||||
bool isValid(String fullText, Match match) {
|
||||
if (_excludes.isEmpty) return true;
|
||||
|
||||
// Extract the full token surrounding the match
|
||||
final token = _getSurroundingToken(fullText, match.start, match.end);
|
||||
final lowerToken = token.toLowerCase();
|
||||
|
||||
// Exact match with the token (e.g., "file-246")
|
||||
if (_excludes.contains(lowerToken)) return false;
|
||||
|
||||
// Match with trailing punctuation removed (e.g., "file246." -> "file246")
|
||||
final cleanToken = lowerToken.replaceAll(RegExp(r'[^\w\s]+$'), '');
|
||||
if (_excludes.contains(cleanToken)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Extracts the surrounding token without creating garbage strings.
|
||||
/// Instead of using .trim() inside a loop (which allocates memory),
|
||||
/// we check characters directly.
|
||||
String _getSurroundingToken(String text, int matchStart, int matchEnd) {
|
||||
int start = matchStart;
|
||||
int end = matchEnd;
|
||||
|
||||
// Expand to the left until whitespace or start of string
|
||||
while (start > 0) {
|
||||
// Check the character before the current start index
|
||||
if (_whitespaceRegExp.hasMatch(text[start - 1])) break;
|
||||
start--;
|
||||
}
|
||||
|
||||
// Expand to the right until whitespace or end of string
|
||||
while (end < text.length) {
|
||||
if (_whitespaceRegExp.hasMatch(text[end])) break;
|
||||
end++;
|
||||
}
|
||||
return text.substring(start, end);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/// Common interface for all keyword filters.
|
||||
abstract class KeywordFilter {
|
||||
/// Returns `true` if the keyword is valid.
|
||||
/// Returns `false` if the keyword should be rejected.
|
||||
bool isValid(String fullText, Match match);
|
||||
}
|
||||
Reference in New Issue
Block a user