import 'package:flutter/foundation.dart' show kIsWeb, compute; import 'package:flutter_sms_inbox/flutter_sms_inbox.dart'; import '../models/subscription_model.dart'; import '../utils/logger.dart'; import '../temp/test_sms_data.dart'; import '../services/subscription_url_matcher.dart'; import '../utils/platform_helper.dart'; import '../utils/business_day_util.dart'; class SmsScanner { final SmsQuery _query = SmsQuery(); Future> scanForSubscriptions() async { try { List smsList; Log.d('SmsScanner: 스캔 시작'); // 플랫폼별 분기 처리 if (kIsWeb) { // 웹 환경: 테스트 데이터 사용 Log.i('SmsScanner: 웹 환경에서 테스트 데이터 사용'); smsList = TestSmsData.getTestData(); Log.d('SmsScanner: 테스트 데이터 개수: ${smsList.length}'); } else if (PlatformHelper.isIOS) { // iOS: SMS 접근 불가, 빈 리스트 반환 Log.w('SmsScanner: iOS에서는 SMS 스캔 불가'); return []; } else if (PlatformHelper.isAndroid) { // Android: flutter_sms_inbox 사용 Log.i('SmsScanner: Android에서 실제 SMS 스캔'); smsList = await _scanAndroidSms(); Log.d('SmsScanner: 스캔된 SMS 개수: ${smsList.length}'); } else { // 기타 플랫폼 Log.w('SmsScanner: 지원하지 않는 플랫폼'); return []; } // SMS 데이터를 분석하여 반복 결제되는 구독 식별 final List subscriptions = []; final Map>> serviceGroups = {}; // 서비스명별로 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}'); // 그룹화된 데이터로 구독 분석 for (final entry in serviceGroups.entries) { Log.d('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}'); // 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}'); } } else { Log.d('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}'); } } Log.d('SmsScanner: 최종 구독 개수: ${subscriptions.length}'); return subscriptions; } catch (e) { Log.e('SmsScanner: 예외 발생', e); throw Exception('SMS 스캔 중 오류 발생: $e'); } } // Android에서 flutter_sms_inbox를 사용한 SMS 스캔 Future> _scanAndroidSms() async { try { final messages = await _query.getAllSms; // Isolate로 넘길 수 있도록 직렬화 (plugin 객체 직접 전달 불가) final serialized = >[]; for (final message in messages) { serialized.add({ 'body': message.body ?? '', 'address': message.address ?? '', 'dateMillis': (message.date ?? DateTime.now()).millisecondsSinceEpoch, }); } // 대량 파싱은 별도 Isolate로 오프로딩 final List> smsList = await compute(_parseRawSmsBatch, serialized); return smsList; } catch (e) { Log.e('SmsScanner: Android SMS 스캔 실패', e); return []; } } // (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체 SubscriptionModel? _parseSms(Map sms, int repeatCount) { try { final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스'; final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0; final billingCycle = SubscriptionModel.normalizeBillingCycle( sms['billingCycle'] as String? ?? 'monthly'); final nextBillingDateStr = sms['nextBillingDate'] as String?; // 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨) final actualRepeatCount = repeatCount > 0 ? repeatCount : 1; final message = sms['message'] as String? ?? ''; // 통화 단위 감지 - 메시지 내용과 서비스명 모두 검사 String currency = _detectCurrency(message); // 서비스명에 따라 통화 단위 재확인 final dollarServices = [ 'GitHub', 'GitHub Pro', 'Netflix US', 'Spotify', 'Spotify Premium' ]; if (dollarServices.any((service) => serviceName.contains(service))) { Log.d('서비스명 $serviceName으로 USD 통화 단위 확정'); currency = 'USD'; } DateTime? nextBillingDate; // 외부에서 계산된 다음 결제일이 있으면 우선 사용 final overrideNext = sms['overrideNextBillingDate'] as String?; if (overrideNext != null) { nextBillingDate = DateTime.tryParse(overrideNext); } else if (nextBillingDateStr != null) { nextBillingDate = DateTime.tryParse(nextBillingDateStr); } DateTime? lastPaymentDate; final previousPaymentDateStr = sms['previousPaymentDate'] as String?; if (previousPaymentDateStr != null && previousPaymentDateStr.isNotEmpty) { lastPaymentDate = DateTime.tryParse(previousPaymentDateStr); } // 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정 DateTime adjustedNextBillingDate = _calculateNextBillingDate( nextBillingDate ?? DateTime.now().add(const Duration(days: 30)), billingCycle, ); // 주말/공휴일 보정 adjustedNextBillingDate = BusinessDayUtil.nextBusinessDay(adjustedNextBillingDate); return SubscriptionModel( id: DateTime.now().millisecondsSinceEpoch.toString(), serviceName: serviceName, monthlyCost: monthlyCost, billingCycle: billingCycle, nextBillingDate: adjustedNextBillingDate, isAutoDetected: true, repeatCount: actualRepeatCount, lastPaymentDate: lastPaymentDate, websiteUrl: _extractWebsiteUrl(serviceName), currency: currency, // 통화 단위 설정 ); } catch (e) { return null; } } // 다음 결제일 계산 (현재 날짜 기준으로 조정) DateTime _calculateNextBillingDate( DateTime billingDate, String billingCycle) { final now = DateTime.now(); // 결제일이 이미 미래인 경우 그대로 반환 if (billingDate.isAfter(now)) { return billingDate; } // 결제 주기별 다음 결제일 계산 if (billingCycle == 'monthly') { int month = now.month; int year = now.year; // 현재 달의 결제일이 이미 지났으면 다음 달로 이동 if (now.day >= billingDate.day) { month = month + 1; if (month > 12) { month = 1; year = year + 1; } } final dim = BusinessDayUtil.daysInMonth(year, month); final day = billingDate.day.clamp(1, dim); return DateTime(year, month, day); } else if (billingCycle == 'yearly') { // 올해의 결제일이 지났는지 확인 final thisYearBilling = DateTime(now.year, billingDate.month, billingDate.day); if (thisYearBilling.isBefore(now)) { return DateTime(now.year + 1, billingDate.month, billingDate.day); } else { return thisYearBilling; } } else if (billingCycle == 'weekly') { // 가장 가까운 다음 주 같은 요일 계산 final dayDifference = billingDate.weekday - now.weekday; final daysToAdd = dayDifference > 0 ? dayDifference : 7 + dayDifference; return now.add(Duration(days: daysToAdd)); } // 기본 처리: 30일 후 return now.add(const Duration(days: 30)); } String? _extractWebsiteUrl(String serviceName) { // SubscriptionUrlMatcher 서비스를 사용하여 URL 매칭 시도 final suggestedUrl = SubscriptionUrlMatcher.findMatchingUrl(serviceName); // 매칭된 URL이 있으면 반환, 없으면 기존 매핑 사용 if (suggestedUrl != null && suggestedUrl.isNotEmpty) { return suggestedUrl; } // 기존 하드코딩된 매핑 (필요한 경우 폴백으로 사용) final Map serviceUrls = { '넷플릭스': 'https://www.netflix.com', '디즈니플러스': 'https://www.disneyplus.com', '유튜브프리미엄': 'https://www.youtube.com', 'YouTube Premium': 'https://www.youtube.com', '애플 iCloud': 'https://www.icloud.com', 'Microsoft 365': 'https://www.microsoft.com/microsoft-365', '멜론': 'https://www.melon.com', '웨이브': 'https://www.wavve.com', 'Apple Music': 'https://www.apple.com/apple-music', 'Netflix': 'https://www.netflix.com', 'Disney+': 'https://www.disneyplus.com', 'Spotify': 'https://www.spotify.com', }; return serviceUrls[serviceName]; } // 메시지에서 통화 단위를 감지하는 함수 String _detectCurrency(String message) { final dollarKeywords = [ '\$', 'USD', 'dollar', '달러', 'dollars', 'US\$', // 해외 서비스 이름 'Netflix US', 'Spotify Premium', 'Apple US', 'Amazon US', 'GitHub' ]; // 특정 서비스명으로 통화 단위 결정 final Map serviceCurrencyMap = { 'Netflix US': 'USD', 'Spotify Premium': 'USD', 'Spotify': 'USD', 'GitHub': 'USD', 'GitHub Pro': 'USD', }; // 서비스명 기반 통화 단위 확인 for (final service in serviceCurrencyMap.keys) { if (message.contains(service)) { Log.d('_detectCurrency: $service는 USD 서비스로 판별됨'); return 'USD'; } } // 메시지에 달러 관련 키워드가 있는지 확인 for (final keyword in dollarKeywords) { if (message.toLowerCase().contains(keyword.toLowerCase())) { Log.d('_detectCurrency: USD 키워드 발견: $keyword'); return 'USD'; } } // 기본값은 원화 return 'KRW'; } } // ===== Isolate 오프로딩용 Top-level 파서 ===== // 대량 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})?)'), // 달러 RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액 ]; final results = >[]; for (final m in messages) { final body = (m['body'] as String?) ?? ''; final sender = (m['address'] as String?) ?? ''; 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 billingCycle = _isoExtractBillingCycle(body); final nextBillingDate = _isoCalculateNextBillingFromDate(date, billingCycle); results.add({ 'serviceName': serviceName, 'monthlyCost': amount, 'billingCycle': billingCycle, 'message': body, 'nextBillingDate': nextBillingDate.toIso8601String(), 'previousPaymentDate': date.toIso8601String(), }); } return results; } String _isoExtractServiceName(String body, String sender) { final servicePatterns = { 'netflix': '넷플릭스', 'youtube': '유튜브 프리미엄', 'spotify': 'Spotify', 'disney': '디즈니플러스', 'apple': 'Apple', 'microsoft': 'Microsoft', 'github': 'GitHub', 'adobe': 'Adobe', '멜론': '멜론', '웨이브': '웨이브', }; final combined = '$body $sender'.toLowerCase(); for (final e in servicePatterns.entries) { if (combined.contains(e.key)) return e.value; } return _isoExtractServiceNameFromSender(sender); } String _isoExtractServiceNameFromSender(String sender) { if (RegExp(r'^\d+$').hasMatch(sender)) { return '알 수 없는 서비스'; } return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim(); } double? _isoExtractAmount(String body, List patterns) { for (final pattern in patterns) { final match = pattern.firstMatch(body); if (match != null) { var amountStr = match.group(1) ?? ''; amountStr = amountStr.replaceAll(',', ''); final parsed = double.tryParse(amountStr); if (parsed != null) return parsed; } } return null; } String _isoExtractBillingCycle(String body) { if (body.contains('월') || body.toLowerCase().contains('monthly') || body.contains('매월')) { return 'monthly'; } else if (body.contains('년') || body.toLowerCase().contains('yearly') || body.toLowerCase().contains('annual')) { return 'yearly'; } else if (body.contains('주') || body.toLowerCase().contains('weekly')) { return 'weekly'; } return 'monthly'; } DateTime _isoCalculateNextBillingFromDate( DateTime lastDate, String billingCycle) { switch (billingCycle) { case 'monthly': return DateTime(lastDate.year, lastDate.month + 1, lastDate.day); case 'yearly': return DateTime(lastDate.year + 1, lastDate.month, lastDate.day); case 'weekly': return lastDate.add(const Duration(days: 7)); default: return lastDate.add(const Duration(days: 30)); } }