feat: improve sms scan review and detail layouts

This commit is contained in:
JiWoong Sul
2025-11-14 19:33:32 +09:00
parent a9f42f6f01
commit 2cd46a303e
13 changed files with 455 additions and 115 deletions

View File

@@ -40,9 +40,22 @@ class SmsScanner {
return [];
}
final filteredSms = smsList
.whereType<Map<String, dynamic>>()
.where(_isEligibleSubscriptionSms)
.toList();
Log.d(
'SmsScanner: 유효 결제 SMS ${filteredSms.length}건 / 전체 ${smsList.length}');
if (filteredSms.isEmpty) {
Log.w('SmsScanner: 결제 패턴 SMS 미검출');
return [];
}
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
final List<SmsScanResult> subscriptions = [];
final serviceGroups = _groupMessagesByIdentifier(smsList);
final serviceGroups = _groupMessagesByIdentifier(filteredSms);
Log.d('SmsScanner: 그룹화된 키 수: ${serviceGroups.length}');
@@ -355,16 +368,80 @@ class SmsScanner {
}
}
const List<String> _paymentLikeKeywords = [
'승인',
'결제',
'청구',
'charged',
'charge',
'payment',
'billed',
'purchase',
];
const List<String> _blockedKeywords = [
'otp',
'인증',
'보안',
'verification',
'code',
'코드',
'password',
'pw',
'일회성',
'1회용',
'보안문자',
];
bool _containsPaymentKeyword(String message) {
if (message.isEmpty) return false;
final normalized = message.toLowerCase();
return _paymentLikeKeywords.any(
(keyword) => normalized.contains(keyword.toLowerCase()),
);
}
bool _containsBlockedKeyword(String message) {
if (message.isEmpty) return false;
final normalized = message.toLowerCase();
return _blockedKeywords.any(
(keyword) => normalized.contains(keyword.toLowerCase()),
);
}
bool _isEligibleSubscriptionSms(Map<String, dynamic> sms) {
final amount = (sms['monthlyCost'] as num?)?.toDouble();
if (amount == null || amount <= 0) {
return false;
}
final message = sms['message'] as String? ?? '';
final isPaymentLike =
(sms['isPaymentLike'] as bool?) ?? _containsPaymentKeyword(message);
final isBlocked =
(sms['isBlocked'] as bool?) ?? _containsBlockedKeyword(message);
if (!isPaymentLike || isBlocked) {
return false;
}
return true;
}
// ===== Isolate 오프로딩용 Top-level 파서 =====
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
List<Map<String, dynamic>> _parseRawSmsBatch(
List<Map<String, dynamic>> messages) {
final amountPatterns = <RegExp>[
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
RegExp(r'(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:원|₩)'),
RegExp(r'(?:원|₩)\s*(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'),
RegExp(r'(?:(?:US)?\$)\s*(\d+(?:\.\d{1,2})?)', caseSensitive: false),
RegExp(r'(\d+(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:USD|KRW)',
caseSensitive: false),
RegExp(r'(?:USD|KRW)\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)',
caseSensitive: false),
RegExp(r'(?:결제|승인)[^0-9]{0,12}(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'),
];
final results = <Map<String, dynamic>>[];
@@ -377,6 +454,8 @@ List<Map<String, dynamic>> _parseRawSmsBatch(
final date = DateTime.fromMillisecondsSinceEpoch(dateMillis);
final serviceName = _isoExtractServiceName(body, sender);
final amount = _isoExtractAmount(body, amountPatterns);
final isPaymentLike = _containsPaymentKeyword(body);
final isBlocked = _containsBlockedKeyword(body);
final billingCycle = _isoExtractBillingCycle(body);
final nextBillingDate =
_isoCalculateNextBillingFromDate(date, billingCycle);
@@ -391,6 +470,8 @@ List<Map<String, dynamic>> _parseRawSmsBatch(
'normalizedBody': normalizedBody,
'nextBillingDate': nextBillingDate.toIso8601String(),
'previousPaymentDate': date.toIso8601String(),
'isPaymentLike': isPaymentLike,
'isBlocked': isBlocked,
});
}
@@ -557,6 +638,10 @@ _RepeatDetectionResult? _detectRepeatingSubscriptions(
if (matchedIndices.length < 2) return null;
final hasValidInterval = matchedPairs.any((pair) =>
pair.type == _MatchType.monthly || pair.type == _MatchType.yearly);
if (!hasValidInterval) return null;
final baseIndex = matchedIndices
.reduce((value, element) => value < element ? value : element);
final baseMessage = Map<String, dynamic>.from(sorted[baseIndex]);