feat: SMS 스캔 패키지를 flutter_sms_inbox로 변경 및 플랫폼별 최적화
- telephony 패키지를 flutter_sms_inbox로 교체 - 플랫폼별 SMS 스캔 로직 구현: * Web: mock data 사용 * Android: flutter_sms_inbox로 실제 SMS 스캔 * iOS: SMS 기능 비활성화 - iOS에서 SMS 스캔 버튼 숨김 처리 - PlatformHelper 유틸리티 추가로 웹 환경 오류 해결 - Android 네이티브 MethodChannel 코드 제거
This commit is contained in:
@@ -1,32 +1,37 @@
|
||||
import 'package:flutter/services.dart';
|
||||
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 'package:flutter/foundation.dart' show kDebugMode;
|
||||
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;
|
||||
print('SmsScanner: 스캔 시작');
|
||||
|
||||
// 디버그 모드에서는 테스트 데이터 사용
|
||||
if (kDebugMode) {
|
||||
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: 실제 SMS 데이터 스캔');
|
||||
// 실제 환경에서는 네이티브 코드 호출
|
||||
const platform = MethodChannel('com.submanager/sms');
|
||||
try {
|
||||
smsList = await platform.invokeMethod('scanSubscriptions');
|
||||
print('SmsScanner: 네이티브 호출 성공, SMS 데이터 개수: ${smsList.length}');
|
||||
} catch (e) {
|
||||
print('SmsScanner: 네이티브 호출 실패: $e');
|
||||
// 오류 발생 시 빈 목록 반환
|
||||
return [];
|
||||
}
|
||||
// 기타 플랫폼
|
||||
print('SmsScanner: 지원하지 않는 플랫폼');
|
||||
return [];
|
||||
}
|
||||
|
||||
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
|
||||
@@ -72,6 +77,166 @@ class SmsScanner {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
print('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) {
|
||||
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<String, dynamic> sms, int repeatCount) {
|
||||
try {
|
||||
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
||||
@@ -242,4 +407,4 @@ class SmsScanner {
|
||||
// 기본값은 원화
|
||||
return 'KRW';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,35 @@
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||
import '../utils/platform_helper.dart';
|
||||
|
||||
class SMSService {
|
||||
static const platform = MethodChannel('com.submanager/sms');
|
||||
|
||||
static Future<bool> requestSMSPermission() async {
|
||||
if (kIsWeb) return false;
|
||||
final status = await permission.Permission.sms.request();
|
||||
return status.isGranted;
|
||||
// 웹이나 iOS에서는 SMS 권한 불필요
|
||||
if (kIsWeb || PlatformHelper.isIOS) return true;
|
||||
|
||||
// Android에서만 권한 요청
|
||||
if (PlatformHelper.isAndroid) {
|
||||
final status = await permission.Permission.sms.request();
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> hasSMSPermission() async {
|
||||
if (kIsWeb) return false;
|
||||
final status = await permission.Permission.sms.status;
|
||||
return status.isGranted;
|
||||
// 웹이나 iOS에서는 항상 true 반환 (권한 불필요)
|
||||
if (kIsWeb || PlatformHelper.isIOS) return true;
|
||||
|
||||
// Android에서만 실제 권한 확인
|
||||
if (PlatformHelper.isAndroid) {
|
||||
final status = await permission.Permission.sms.status;
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> scanSubscriptions() async {
|
||||
|
||||
Reference in New Issue
Block a user