- 설정 화면에 SMS 권한 카드 추가: 상태 표시(허용/미허용/영구 거부), 권한 요청/설정 이동 지원\n- 기존 알림 권한 카드 스타일과 일관성 유지 feat(permissions): 최초 실행 시 SMS 권한 온보딩 화면 추가 및 Splash에서 라우팅 (Android) - 권한 필요 이유/수집 범위 현지화 문구 추가\n- 거부/영구거부 케이스 처리 및 설정 이동 chore(codex): AGENTS.md/체크 스크립트/CI/프롬프트 템플릿 추가 - AGENTS.md, scripts/check.sh, scripts/fix.sh, .github/workflows/flutter_ci.yml, .claude/agents/codex.md, 문서 템플릿 추가 refactor(logging): 경로별 print 제거 후 경량 로거(Log) 도입 - SMS 스캐너/컨트롤러, URL 매처, 데이터 리포지토리, 내비게이션, 메모리/성능 유틸 등 핵심 경로 치환 feat(exchange): 환율 API URL을 --dart-define로 오버라이드 가능 + 폴백 로깅 강화 test: URL 매처/환율 스모크 테스트 추가 chore(android): RECEIVE_SMS 권한 제거 (READ_SMS만 유지) fix(lints): dart fix + 수동 정리로 경고 대폭 감소, 비동기 context(mounted) 보강 fix(deprecations):\n- flutter_local_notifications의 androidAllowWhileIdle → androidScheduleMode 전환\n- WillPopScope → PopScope 교체 i18n: SMS 권한 온보딩/설정 문구 현지화 키 추가
432 lines
14 KiB
Dart
432 lines
14 KiB
Dart
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
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';
|
|
|
|
class SmsScanner {
|
|
final SmsQuery _query = SmsQuery();
|
|
|
|
Future<List<SubscriptionModel>> scanForSubscriptions() async {
|
|
try {
|
|
List<dynamic> 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<SubscriptionModel> subscriptions = [];
|
|
final Map<String, List<Map<String, dynamic>>> 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) {
|
|
final serviceSms = entry.value[0]; // 가장 최근 SMS 사용
|
|
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<List<dynamic>> _scanAndroidSms() async {
|
|
try {
|
|
final messages = await _query.getAllSms;
|
|
final smsList = <Map<String, dynamic>>[];
|
|
|
|
// SMS 메시지를 분석하여 구독 서비스 감지
|
|
for (final message in messages) {
|
|
final parsedData = _parseRawSms(message);
|
|
if (parsedData != null) {
|
|
smsList.add(parsedData);
|
|
}
|
|
}
|
|
|
|
return smsList;
|
|
} catch (e) {
|
|
Log.e('SmsScanner: Android SMS 스캔 실패', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// 실제 SMS 메시지를 파싱하여 구독 정보 추출
|
|
Map<String, dynamic>? _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) {
|
|
Log.e('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<String, dynamic> 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;
|
|
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<String, String> 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<String, String> 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';
|
|
}
|
|
}
|