import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter_sms_inbox/flutter_sms_inbox.dart'; import '../models/subscription_model.dart'; import '../temp/test_sms_data.dart'; import '../services/subscription_url_matcher.dart'; import '../utils/platform_helper.dart'; class SmsScanner { final SmsQuery _query = SmsQuery(); Future> scanForSubscriptions() async { try { List smsList; print('SmsScanner: 스캔 시작'); // 플랫폼별 분기 처리 if (kIsWeb) { // 웹 환경: 테스트 데이터 사용 print('SmsScanner: 웹 환경에서 테스트 데이터 사용'); smsList = TestSmsData.getTestData(); print('SmsScanner: 테스트 데이터 개수: ${smsList.length}'); } else if (PlatformHelper.isIOS) { // iOS: SMS 접근 불가, 빈 리스트 반환 print('SmsScanner: iOS에서는 SMS 스캔 불가'); return []; } else if (PlatformHelper.isAndroid) { // Android: flutter_sms_inbox 사용 print('SmsScanner: Android에서 실제 SMS 스캔'); smsList = await _scanAndroidSms(); print('SmsScanner: 스캔된 SMS 개수: ${smsList.length}'); } else { // 기타 플랫폼 print('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); } print('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}'); // 그룹화된 데이터로 구독 분석 for (final entry in serviceGroups.entries) { print('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}'); // 2회 이상 반복된 서비스만 구독으로 간주 if (entry.value.length >= 2) { final serviceSms = entry.value[0]; // 가장 최근 SMS 사용 final subscription = _parseSms(serviceSms, entry.value.length); if (subscription != null) { print( 'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}'); subscriptions.add(subscription); } else { print('SmsScanner: 구독 파싱 실패: ${entry.key}'); } } else { print('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}'); } } print('SmsScanner: 최종 구독 개수: ${subscriptions.length}'); return subscriptions; } catch (e) { print('SmsScanner: 예외 발생: $e'); throw Exception('SMS 스캔 중 오류 발생: $e'); } } // Android에서 flutter_sms_inbox를 사용한 SMS 스캔 Future> _scanAndroidSms() async { try { final messages = await _query.getAllSms; final smsList = >[]; // SMS 메시지를 분석하여 구독 서비스 감지 for (final message in messages) { final parsedData = _parseRawSms(message); if (parsedData != null) { smsList.add(parsedData); } } return smsList; } catch (e) { print('SmsScanner: Android SMS 스캔 실패: $e'); return []; } } // 실제 SMS 메시지를 파싱하여 구독 정보 추출 Map? _parseRawSms(SmsMessage message) { try { final body = message.body ?? ''; final sender = message.address ?? ''; final date = message.date ?? DateTime.now(); // 구독 서비스 키워드 매칭 final subscriptionKeywords = [ '구독', '결제', '정기결제', '자동결제', '월정액', 'subscription', 'payment', 'billing', 'charge', '넷플릭스', 'Netflix', '유튜브', 'YouTube', 'Spotify', '멜론', '웨이브', 'Disney+', '디즈니플러스', 'Apple', 'Microsoft', 'GitHub', 'Adobe', 'Amazon' ]; // 구독 관련 키워드가 있는지 확인 bool isSubscription = subscriptionKeywords.any((keyword) => body.toLowerCase().contains(keyword.toLowerCase()) || sender.toLowerCase().contains(keyword.toLowerCase())); if (!isSubscription) { return null; } // 서비스명 추출 String serviceName = _extractServiceName(body, sender); // 금액 추출 double? amount = _extractAmount(body); // 결제 주기 추출 String billingCycle = _extractBillingCycle(body); return { 'serviceName': serviceName, 'monthlyCost': amount ?? 0.0, 'billingCycle': billingCycle, 'message': body, 'nextBillingDate': _calculateNextBillingFromDate(date, billingCycle).toIso8601String(), 'previousPaymentDate': date.toIso8601String(), }; } catch (e) { print('SmsScanner: SMS 파싱 실패: $e'); return null; } } // 서비스명 추출 로직 String _extractServiceName(String body, String sender) { // 알려진 서비스 매핑 final servicePatterns = { 'netflix': '넷플릭스', 'youtube': '유튜브 프리미엄', 'spotify': 'Spotify', 'disney': '디즈니플러스', 'apple': 'Apple', 'microsoft': 'Microsoft', 'github': 'GitHub', 'adobe': 'Adobe', '멜론': '멜론', '웨이브': '웨이브', }; // 메시지나 발신자에서 서비스명 찾기 final combinedText = '$body $sender'.toLowerCase(); for (final entry in servicePatterns.entries) { if (combinedText.contains(entry.key)) { return entry.value; } } // 찾지 못한 경우 return _extractServiceNameFromSender(sender); } // 발신자 정보에서 서비스명 추출 String _extractServiceNameFromSender(String sender) { // 숫자만 있으면 제거 if (RegExp(r'^\d+$').hasMatch(sender)) { return '알 수 없는 서비스'; } // 특수문자 제거하고 서비스명으로 사용 return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim(); } // 금액 추출 로직 double? _extractAmount(String body) { // 다양한 금액 패턴 매칭 final patterns = [ 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})*)'), // 결제 금액 ]; for (final pattern in patterns) { final match = pattern.firstMatch(body); if (match != null) { String amountStr = match.group(1) ?? ''; amountStr = amountStr.replaceAll(',', ''); return double.tryParse(amountStr); } } return null; } // 결제 주기 추출 로직 String _extractBillingCycle(String body) { if (body.contains('월') || body.contains('monthly') || body.contains('매월')) { return 'monthly'; } else if (body.contains('년') || body.contains('yearly') || body.contains('annual')) { return 'yearly'; } else if (body.contains('주') || body.contains('weekly')) { return 'weekly'; } // 기본값 return 'monthly'; } // 다음 결제일 계산 DateTime _calculateNextBillingFromDate( 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)); } } 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))) { print('서비스명 $serviceName으로 USD 통화 단위 확정'); currency = 'USD'; } DateTime? nextBillingDate; 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); 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; } } return DateTime(year, month, billingDate.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)) { print('_detectCurrency: ${service}는 USD 서비스로 판별됨'); return 'USD'; } } // 메시지에 달러 관련 키워드가 있는지 확인 for (final keyword in dollarKeywords) { if (message.toLowerCase().contains(keyword.toLowerCase())) { print('_detectCurrency: USD 키워드 발견: $keyword'); return 'USD'; } } // 기본값은 원화 return 'KRW'; } }