From 8cec03f181d3f3788adae336b48a1bd57d2a5e5a Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Fri, 14 Nov 2025 14:29:32 +0900 Subject: [PATCH] feat: enhance sms scanner repeat detection --- lib/services/sms_scanner.dart | 407 ++++++++++++++++++++++++---------- 1 file changed, 291 insertions(+), 116 deletions(-) diff --git a/lib/services/sms_scanner.dart b/lib/services/sms_scanner.dart index 4db708d..41362d3 100644 --- a/lib/services/sms_scanner.dart +++ b/lib/services/sms_scanner.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/foundation.dart' show kIsWeb, compute; import 'package:flutter_sms_inbox/flutter_sms_inbox.dart'; import '../models/subscription_model.dart'; @@ -38,90 +40,26 @@ class SmsScanner { // SMS 데이터를 분석하여 반복 결제되는 구독 식별 final List subscriptions = []; - final Map>> serviceGroups = {}; + final serviceGroups = _groupMessagesByIdentifier(smsList); - // 서비스명별로 SMS 메시지 그룹화 - for (final sms in smsList) { - final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스'; - if (!serviceGroups.containsKey(serviceName)) { - serviceGroups[serviceName] = []; - } - serviceGroups[serviceName]!.add(sms); - } + Log.d('SmsScanner: 그룹화된 키 수: ${serviceGroups.length}'); - Log.d('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}'); - - // 그룹화된 데이터로 구독 분석 for (final entry in serviceGroups.entries) { - Log.d('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}'); + Log.d('SmsScanner: 그룹 "${entry.key}" - 메시지 ${entry.value.length}건'); + final repeatResult = _detectRepeatingSubscriptions(entry.value); + if (repeatResult == null) { + Log.d('SmsScanner: 반복 조건 불충족 - ${entry.key}'); + continue; + } - // 2회 이상 반복된 서비스만 구독으로 간주 - if (entry.value.length >= 2) { - // 결제일 패턴 유추를 위해 최근 2개의 결제일을 사용 - final messages = [...entry.value]; - messages.sort((a, b) { - final da = DateTime.tryParse(a['previousPaymentDate'] ?? '') ?? - DateTime(1970); - final db = DateTime.tryParse(b['previousPaymentDate'] ?? '') ?? - DateTime(1970); - return db.compareTo(da); // desc - }); - - final mostRecent = messages.first; - DateTime? recentDate = - DateTime.tryParse(mostRecent['previousPaymentDate'] ?? ''); - DateTime? prevDate = messages.length > 1 - ? DateTime.tryParse(messages[1]['previousPaymentDate'] ?? '') - : null; - - // 기본 결제 일자(일단위) 추정: 가장 최근 결제의 일자 - int baseDay = recentDate?.day ?? DateTime.now().day; - - // 이전 결제가 주말 이월로 보이는 패턴인지 검사하여 baseDay 보정 - if (recentDate != null && prevDate != null) { - final candidate = DateTime(prevDate.year, prevDate.month, baseDay); - if (BusinessDayUtil.isWeekend(candidate)) { - final diff = prevDate.difference(candidate).inDays; - if (diff >= 1 && diff <= 3) { - // 예: 12일(토)→14일(월) - baseDay = baseDay; // 유지 - } else { - // 차이가 크면 이전 달의 일자를 채택 - baseDay = prevDate.day; - } - } - } - - // 다음 결제일 계산: 기준 일자를 바탕으로 다음 달 또는 이번 달로 설정 후 영업일 보정 - final DateTime now = DateTime.now(); - int year = now.year; - int month = now.month; - if (now.day >= baseDay) { - month += 1; - if (month > 12) { - month = 1; - year += 1; - } - } - final dim = BusinessDayUtil.daysInMonth(year, month); - final day = baseDay.clamp(1, dim); - DateTime nextBilling = DateTime(year, month, day); - nextBilling = BusinessDayUtil.nextBusinessDay(nextBilling); - - // 가장 최근 SMS 맵에 override 값으로 주입 - final serviceSms = Map.from(mostRecent); - serviceSms['overrideNextBillingDate'] = nextBilling.toIso8601String(); - - final subscription = _parseSms(serviceSms, entry.value.length); - if (subscription != null) { - Log.i( - 'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}'); - subscriptions.add(subscription); - } else { - Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}'); - } + final subscription = + _parseSms(repeatResult.baseMessage, repeatResult.repeatCount); + if (subscription != null) { + Log.i( + 'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}'); + subscriptions.add(subscription); } else { - Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}'); + Log.w('SmsScanner: 구독 파싱 실패: ${entry.key}'); } } @@ -347,33 +285,6 @@ class SmsScanner { // 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환 List> _parseRawSmsBatch( List> messages) { - // 키워드/정규식은 Isolate 내에서 재생성 (간단 복제) - const subscriptionKeywords = [ - '구독', - '결제', - '정기결제', - '자동결제', - '월정액', - 'subscription', - 'payment', - 'billing', - 'charge', - '넷플릭스', - 'Netflix', - '유튜브', - 'YouTube', - 'Spotify', - '멜론', - '웨이브', - 'Disney+', - '디즈니플러스', - 'Apple', - 'Microsoft', - 'GitHub', - 'Adobe', - 'Amazon' - ]; - final amountPatterns = [ RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화 RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러 @@ -389,26 +300,20 @@ List> _parseRawSmsBatch( final dateMillis = (m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch; final date = DateTime.fromMillisecondsSinceEpoch(dateMillis); - - final lowerBody = body.toLowerCase(); - final lowerSender = sender.toLowerCase(); - final isSubscription = subscriptionKeywords.any((k) => - lowerBody.contains(k.toLowerCase()) || - lowerSender.contains(k.toLowerCase())); - - if (!isSubscription) continue; - final serviceName = _isoExtractServiceName(body, sender); - final amount = _isoExtractAmount(body, amountPatterns) ?? 0.0; + final amount = _isoExtractAmount(body, amountPatterns); final billingCycle = _isoExtractBillingCycle(body); final nextBillingDate = _isoCalculateNextBillingFromDate(date, billingCycle); + final normalizedBody = _isoNormalizeBody(body); results.add({ 'serviceName': serviceName, + 'address': sender, 'monthlyCost': amount, 'billingCycle': billingCycle, 'message': body, + 'normalizedBody': normalizedBody, 'nextBillingDate': nextBillingDate.toIso8601String(), 'previousPaymentDate': date.toIso8601String(), }); @@ -473,6 +378,23 @@ String _isoExtractBillingCycle(String body) { return 'monthly'; } +String _isoNormalizeBody(String body) { + final patterns = [ + RegExp(r'\d{4}[./-]\d{1,2}[./-]\d{1,2}'), + RegExp(r'\d{1,2}[./-]\d{1,2}[./-]\d{2,4}'), + RegExp(r'\d{4}\s*년\s*\d{1,2}\s*월\s*\d{1,2}\s*일'), + RegExp(r'\d{1,2}\s*월\s*\d{1,2}\s*일'), + RegExp(r'\d{1,2}:\d{2}'), + ]; + + var normalized = body; + for (final pattern in patterns) { + normalized = normalized.replaceAll(pattern, ' '); + } + + return normalized.replaceAll(RegExp(r'\s+'), ' ').trim().toLowerCase(); +} + DateTime _isoCalculateNextBillingFromDate( DateTime lastDate, String billingCycle) { switch (billingCycle) { @@ -486,3 +408,256 @@ DateTime _isoCalculateNextBillingFromDate( return lastDate.add(const Duration(days: 30)); } } + +Map>> _groupMessagesByIdentifier( + List smsList) { + final Map>> groups = {}; + + for (final smsEntry in smsList) { + if (smsEntry is! Map) continue; + final sms = Map.from(smsEntry as Map); + final serviceName = (sms['serviceName'] as String?)?.trim(); + final address = (sms['address'] as String?)?.trim(); + final sender = (sms['sender'] as String?)?.trim(); + + String key = (serviceName != null && + serviceName.isNotEmpty && + serviceName != '알 수 없는 서비스') + ? serviceName + : (address?.isNotEmpty == true + ? address! + : (sender?.isNotEmpty == true ? sender! : 'unknown')); + + groups.putIfAbsent(key, () => []).add(sms); + } + + return groups; +} + +class _RepeatDetectionResult { + _RepeatDetectionResult({ + required this.baseMessage, + required this.repeatCount, + }); + + final Map baseMessage; + final int repeatCount; +} + +enum _MatchType { none, monthly, yearly, identical } + +class _MatchedPair { + _MatchedPair(this.first, this.second, this.type); + + final int first; + final int second; + final _MatchType type; +} + +_RepeatDetectionResult? _detectRepeatingSubscriptions( + List> messages) { + if (messages.length < 2) return null; + + final sorted = messages.map((sms) => Map.from(sms)).toList() + ..sort((a, b) { + final da = _parsePaymentDate(a['previousPaymentDate']); + final db = _parsePaymentDate(b['previousPaymentDate']); + return (db ?? DateTime.fromMillisecondsSinceEpoch(0)) + .compareTo(da ?? DateTime.fromMillisecondsSinceEpoch(0)); + }); + + final matchedIndices = {}; + final matchedPairs = <_MatchedPair>[]; + + for (int i = 0; i < sorted.length - 1; i++) { + for (int j = i + 1; j < sorted.length && j <= i + 5; j++) { + final matchType = _evaluateMatch(sorted[i], sorted[j]); + if (matchType == _MatchType.none) continue; + matchedIndices.add(i); + matchedIndices.add(j); + matchedPairs.add(_MatchedPair(i, j, matchType)); + break; + } + } + + if (matchedIndices.length < 2) return null; + + final baseIndex = matchedIndices + .reduce((value, element) => value < element ? value : element); + final baseMessage = Map.from(sorted[baseIndex]); + + final overrideDate = _deriveNextBillingDate(sorted, matchedPairs); + if (overrideDate != null) { + baseMessage['overrideNextBillingDate'] = overrideDate.toIso8601String(); + } + + return _RepeatDetectionResult( + baseMessage: baseMessage, + repeatCount: matchedIndices.length, + ); +} + +_MatchType _evaluateMatch( + Map recent, Map previous) { + final amountMatch = _matchByAmountAndInterval(recent, previous); + if (amountMatch != _MatchType.none) { + return amountMatch; + } + + if (_areBodiesEquivalent(recent, previous)) { + final inferredInterval = _classifyIntervalByDates(recent, previous); + return inferredInterval == _MatchType.none + ? _MatchType.identical + : inferredInterval; + } + + return _MatchType.none; +} + +_MatchType _matchByAmountAndInterval( + Map a, Map b) { + final amountA = (a['monthlyCost'] as num?)?.toDouble(); + final amountB = (b['monthlyCost'] as num?)?.toDouble(); + if (amountA == null || amountB == null) return _MatchType.none; + if (!_isAmountSimilar(amountA, amountB)) return _MatchType.none; + return _classifyIntervalByDates(a, b); +} + +_MatchType _classifyIntervalByDates( + Map a, Map b) { + final dateA = _parsePaymentDate(a['previousPaymentDate']); + final dateB = _parsePaymentDate(b['previousPaymentDate']); + if (dateA == null || dateB == null) return _MatchType.none; + final diffDays = (dateA.difference(dateB).inDays).abs(); + if (diffDays >= 27 && diffDays <= 34) { + return _MatchType.monthly; + } + if (diffDays >= 350 && diffDays <= 380) { + return _MatchType.yearly; + } + return _MatchType.none; +} + +bool _areBodiesEquivalent(Map a, Map b) { + final normalizedA = _getNormalizedBody(a); + final normalizedB = _getNormalizedBody(b); + if (normalizedA.isEmpty || normalizedB.isEmpty) return false; + return normalizedA == normalizedB; +} + +String _getNormalizedBody(Map sms) { + final cached = sms['normalizedBody'] as String?; + if (cached != null && cached.isNotEmpty) return cached; + final message = sms['message'] as String? ?? ''; + final normalized = _isoNormalizeBody(message); + sms['normalizedBody'] = normalized; + return normalized; +} + +DateTime? _deriveNextBillingDate( + List> sorted, List<_MatchedPair> pairs) { + if (pairs.isEmpty) return null; + + final targetPair = pairs.firstWhere( + (pair) => pair.type == _MatchType.monthly || pair.type == _MatchType.yearly, + orElse: () => pairs.first, + ); + + final recent = sorted[targetPair.first]; + final previous = sorted[targetPair.second]; + final recentDate = _parsePaymentDate(recent['previousPaymentDate']); + final prevDate = _parsePaymentDate(previous['previousPaymentDate']); + + return _calculateNextBillingFromPair(recentDate, prevDate, targetPair.type); +} + +DateTime? _calculateNextBillingFromPair( + DateTime? recentDate, DateTime? prevDate, _MatchType type) { + if (recentDate == null) return null; + + if (type == _MatchType.monthly) { + DateTime candidate = _addMonths(recentDate, 1); + while (!candidate.isAfter(DateTime.now())) { + candidate = _addMonths(candidate, 1); + } + return BusinessDayUtil.nextBusinessDay(candidate); + } + + if (type == _MatchType.yearly) { + DateTime candidate = DateTime( + recentDate.year + 1, + recentDate.month, + _clampDay( + recentDate.day, + BusinessDayUtil.daysInMonth(recentDate.year + 1, recentDate.month), + ), + ); + while (!candidate.isAfter(DateTime.now())) { + candidate = DateTime(candidate.year + 1, candidate.month, candidate.day); + } + return BusinessDayUtil.nextBusinessDay(candidate); + } + + return _inferMonthlyNextBilling(recentDate, prevDate); +} + +DateTime? _inferMonthlyNextBilling(DateTime recentDate, DateTime? prevDate) { + int baseDay = recentDate.day; + + if (prevDate != null) { + final candidate = DateTime(prevDate.year, prevDate.month, baseDay); + if (BusinessDayUtil.isWeekend(candidate)) { + final diff = prevDate.difference(candidate).inDays; + if (diff < 1 || diff > 3) { + baseDay = prevDate.day; + } + } + } + + final now = DateTime.now(); + int year = now.year; + int month = now.month; + if (now.day >= baseDay) { + month += 1; + if (month > 12) { + month = 1; + year += 1; + } + } + + final dim = BusinessDayUtil.daysInMonth(year, month); + final day = _clampDay(baseDay, dim); + var nextBilling = DateTime(year, month, day); + return BusinessDayUtil.nextBusinessDay(nextBilling); +} + +DateTime _addMonths(DateTime date, int months) { + final totalMonths = (date.month - 1) + months; + final year = date.year + totalMonths ~/ 12; + final month = totalMonths % 12 + 1; + final dim = BusinessDayUtil.daysInMonth(year, month); + final day = _clampDay(date.day, dim); + return DateTime(year, month, day); +} + +int _clampDay(int day, int maxDay) { + if (day < 1) return 1; + if (day > maxDay) return maxDay; + return day; +} + +DateTime? _parsePaymentDate(dynamic value) { + if (value is DateTime) return value; + if (value is String && value.isNotEmpty) { + return DateTime.tryParse(value); + } + return null; +} + +bool _isAmountSimilar(double a, double b) { + final diff = (a - b).abs(); + final base = math.max(a.abs(), b.abs()); + final tolerance = base * 0.01; // 1% 허용 + final minTolerance = base < 10 ? 0.1 : 1.0; + return diff <= math.max(tolerance, minTolerance); +}