From a9a715d67c69c006cf623fa5fe4308af339133f4 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 17 Jul 2025 18:30:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20SMS=20=EC=8A=A4=EC=BA=94=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EB=A5=BC=20flutter=5Fsms=5Finbox=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=ED=94=8C=EB=9E=AB=ED=8F=BC?= =?UTF-8?q?=EB=B3=84=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - telephony 패키지를 flutter_sms_inbox로 교체 - 플랫폼별 SMS 스캔 로직 구현: * Web: mock data 사용 * Android: flutter_sms_inbox로 실제 SMS 스캔 * iOS: SMS 기능 비활성화 - iOS에서 SMS 스캔 버튼 숨김 처리 - PlatformHelper 유틸리티 추가로 웹 환경 오류 해결 - Android 네이티브 MethodChannel 코드 제거 --- .claude/settings.json | 8 + .../com/example/submanager/MainActivity.kt | 196 +---------------- lib/screens/main_screen.dart | 9 +- lib/services/sms_scanner.dart | 199 ++++++++++++++++-- lib/services/sms_service.dart | 27 ++- lib/utils/platform_helper.dart | 24 +++ lib/widgets/floating_navigation_bar.dart | 18 +- pubspec.lock | 16 +- pubspec.yaml | 3 +- 9 files changed, 259 insertions(+), 241 deletions(-) create mode 100644 .claude/settings.json create mode 100644 lib/utils/platform_helper.dart diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..bd2d93e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/submanager/MainActivity.kt b/android/app/src/main/kotlin/com/example/submanager/MainActivity.kt index b491d8c..e3be1e5 100644 --- a/android/app/src/main/kotlin/com/example/submanager/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/submanager/MainActivity.kt @@ -1,198 +1,8 @@ package com.example.submanager import io.flutter.embedding.android.FlutterActivity -import io.flutter.embedding.engine.FlutterEngine -import io.flutter.plugin.common.MethodChannel -import android.content.Context -import android.database.ContentObserver -import android.net.Uri -import android.os.Handler -import android.os.Looper -import android.provider.Telephony -import org.json.JSONObject -import java.text.SimpleDateFormat -import java.util.* class MainActivity: FlutterActivity() { - private val CHANNEL = "com.submanager/sms" - - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> - when (call.method) { - "scanSubscriptions" -> { - scanSubscriptions(result) - } - else -> { - result.notImplemented() - } - } - } - } - - private fun scanSubscriptions(result: MethodChannel.Result) { - try { - // 메시지를 임시 저장할 맵 - val messageGroups = mutableMapOf>>() - - val cursor = contentResolver.query( - Telephony.Sms.CONTENT_URI, - arrayOf( - Telephony.Sms.ADDRESS, - Telephony.Sms.BODY, - Telephony.Sms.DATE - ), - null, - null, - "${Telephony.Sms.DATE} DESC" - ) - - cursor?.use { - while (it.moveToNext()) { - val address = it.getString(0) - val body = it.getString(1) - val date = it.getLong(2) - - // 구독 관련 키워드로 필터링 - if (isSubscriptionMessage(body)) { - val parsedMessage = parseSubscriptionMessage(body, address, date) - if (parsedMessage != null) { - val key = "${address}_${parsedMessage["monthlyCost"]}" - if (!messageGroups.containsKey(key)) { - messageGroups[key] = mutableListOf() - } - messageGroups[key]?.add(parsedMessage) - } - } - } - } - - // 최종 구독 목록 - val subscriptions = mutableListOf>() - - // 동일한 발신자와 유사한 금액으로 2회 이상 메시지가 있는 경우만 구독으로 판단 - for ((key, messages) in messageGroups) { - if (messages.size >= 2) { - // 가장 최근 메시지 정보를 사용 - val latestMessage = messages.first() - - // 주기성 추가 - val enhancedMessage = latestMessage.toMutableMap() - enhancedMessage["repeatCount"] = messages.size - enhancedMessage["isRecurring"] = true - - // 이전 결제일도 추가 - if (messages.size > 1) { - enhancedMessage["previousPaymentDate"] = messages[1]["nextBillingDate"] ?: "" - } - - subscriptions.add(enhancedMessage) - } - } - - result.success(subscriptions) - } catch (e: Exception) { - result.error("SMS_SCAN_ERROR", e.message, null) - } - } - - private fun isSubscriptionMessage(body: String?): Boolean { - if (body == null) return false - val keywords = listOf( - "구독", "결제", "청구", "요금", "월정액", "정기결제", - "subscription", "payment", "bill", "fee", "monthly", "recurring" - ) - return keywords.any { body.contains(it) } - } - - private fun parseSubscriptionMessage( - body: String, - address: String, - date: Long - ): Map? { - try { - // 서비스명 추출 (예: "넷플릭스", "스포티파이" 등) - val serviceName = extractServiceName(address, body) - - // 금액 추출 (예: "9,900원", "₩9,900" 등) - val amount = extractAmount(body) - - // 결제 주기 추출 (예: "월간", "연간" 등) - val billingCycle = extractBillingCycle(body) - - // 다음 결제일 추출 - val nextBillingDate = extractNextBillingDate(body, date) - - return mapOf( - "serviceName" to serviceName, - "monthlyCost" to amount, - "billingCycle" to billingCycle, - "nextBillingDate" to nextBillingDate, - "isRecurring" to false, - "repeatCount" to 1, - "sender" to address, - "messageDate" to formatDate(date) - ) - } catch (e: Exception) { - return null - } - } - - private fun formatDate(timestamp: Long): String { - val date = Date(timestamp) - val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) - return formatter.format(date) - } - - private fun extractServiceName(address: String, body: String): String { - // 주소에서 서비스명 추출 시도 - val addressPattern = Regex("""([가-힣a-zA-Z]+)""") - val addressMatch = addressPattern.find(address) - if (addressMatch != null) { - return addressMatch.value - } - - // 본문에서 서비스명 추출 시도 - val bodyPattern = Regex("""([가-힣a-zA-Z]+)\s*(구독|결제|청구)""") - val bodyMatch = bodyPattern.find(body) - if (bodyMatch != null) { - return bodyMatch.groupValues[1] - } - - return "알 수 없는 서비스" - } - - private fun extractAmount(body: String): Double { - val pattern = Regex("""(\d{1,3}(?:,\d{3})*)(?:\s*원|\s*₩)""") - val match = pattern.find(body) - return if (match != null) { - match.groupValues[1].replace(",", "").toDouble() - } else { - 0.0 - } - } - - private fun extractBillingCycle(body: String): String { - return when { - body.contains("월간") || body.contains("월정액") -> "월간" - body.contains("연간") || body.contains("연정액") -> "연간" - body.contains("주간") || body.contains("주정액") -> "주간" - else -> "월간" - } - } - - private fun extractNextBillingDate(body: String, messageDate: Long): String { - val datePattern = Regex("""(\d{4})[년/.-](\d{1,2})[월/.-](\d{1,2})일?""") - val match = datePattern.find(body) - if (match != null) { - val (year, month, day) = match.destructured - return "$year-${month.padStart(2, '0')}-${day.padStart(2, '0')}" - } - - // 날짜를 찾을 수 없는 경우, 메시지 날짜로부터 1개월 후를 반환 - val calendar = Calendar.getInstance() - calendar.timeInMillis = messageDate - calendar.add(Calendar.MONTH, 1) - return SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(calendar.time) - } -} + // flutter_sms_inbox 패키지가 SMS 처리를 담당하므로 + // 기존 MethodChannel 코드는 제거되었습니다 +} \ No newline at end of file diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 56f19ae..3c3cf15 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -14,6 +14,7 @@ import '../widgets/floating_navigation_bar.dart'; import '../widgets/glassmorphic_scaffold.dart'; import '../widgets/home_content.dart'; import '../l10n/app_localizations.dart'; +import '../utils/platform_helper.dart'; class MainScreen extends StatefulWidget { const MainScreen({super.key}); @@ -69,7 +70,7 @@ class _MainScreenState extends State onShow: () {}, ); - // 화면 목록 초기화 + // 화면 목록 초기화 (iOS에서는 SMS 스캔 제외) _screens = [ HomeContent( fadeController: _fadeController, @@ -82,7 +83,7 @@ class _MainScreenState extends State ), const AnalysisScreen(), Container(), // 추가 버튼은 별도 처리 - const SmsScanScreen(), + if (!PlatformHelper.isIOS) const SmsScanScreen(), const SettingsScreen(), ]; } @@ -233,7 +234,9 @@ class _MainScreenState extends State return GlassmorphicScaffold( body: IndexedStack( - index: currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex, + index: PlatformHelper.isIOS + ? (currentIndex == 3 ? 3 : currentIndex) // iOS: 설정화면은 인덱스 3 + : (currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex), // Android: 기존 로직 children: _screens, ), backgroundGradient: backgroundGradient, diff --git a/lib/services/sms_scanner.dart b/lib/services/sms_scanner.dart index 83af72d..e2b4db6 100644 --- a/lib/services/sms_scanner.dart +++ b/lib/services/sms_scanner.dart @@ -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> scanForSubscriptions() async { try { List 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> _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? ?? '알 수 없는 서비스'; @@ -242,4 +407,4 @@ class SmsScanner { // 기본값은 원화 return 'KRW'; } -} +} \ No newline at end of file diff --git a/lib/services/sms_service.dart b/lib/services/sms_service.dart index 5ae7634..e1c4f63 100644 --- a/lib/services/sms_service.dart +++ b/lib/services/sms_service.dart @@ -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 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 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>> scanSubscriptions() async { diff --git a/lib/utils/platform_helper.dart b/lib/utils/platform_helper.dart new file mode 100644 index 0000000..06f00a4 --- /dev/null +++ b/lib/utils/platform_helper.dart @@ -0,0 +1,24 @@ +import 'package:flutter/foundation.dart'; + +class PlatformHelper { + static bool get isWeb => kIsWeb; + + static bool get isIOS { + if (kIsWeb) return false; + return defaultTargetPlatform == TargetPlatform.iOS; + } + + static bool get isAndroid { + if (kIsWeb) return false; + return defaultTargetPlatform == TargetPlatform.android; + } + + static bool get isMobile => isIOS || isAndroid; + + static bool get isDesktop { + if (kIsWeb) return false; + return defaultTargetPlatform == TargetPlatform.linux || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.windows; + } +} \ No newline at end of file diff --git a/lib/widgets/floating_navigation_bar.dart b/lib/widgets/floating_navigation_bar.dart index bd76066..e1c7e68 100644 --- a/lib/widgets/floating_navigation_bar.dart +++ b/lib/widgets/floating_navigation_bar.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import '../theme/app_colors.dart'; import 'glassmorphism_card.dart'; import '../l10n/app_localizations.dart'; +import '../utils/platform_helper.dart'; class FloatingNavigationBar extends StatefulWidget { final int selectedIndex; @@ -117,17 +118,18 @@ class _FloatingNavigationBarState extends State _AddButton( onTap: () => _onItemTapped(2), ), - _NavigationItem( - icon: Icons.qr_code_scanner_rounded, - label: AppLocalizations.of(context).smsScanLabel, - isSelected: widget.selectedIndex == 3, - onTap: () => _onItemTapped(3), - ), + if (!PlatformHelper.isIOS) + _NavigationItem( + icon: Icons.qr_code_scanner_rounded, + label: AppLocalizations.of(context).smsScanLabel, + isSelected: widget.selectedIndex == 3, + onTap: () => _onItemTapped(3), + ), _NavigationItem( icon: Icons.settings_rounded, label: AppLocalizations.of(context).settings, - isSelected: widget.selectedIndex == 4, - onTap: () => _onItemTapped(4), + isSelected: PlatformHelper.isIOS ? widget.selectedIndex == 3 : widget.selectedIndex == 4, + onTap: () => _onItemTapped(PlatformHelper.isIOS ? 3 : 4), ), ], ), diff --git a/pubspec.lock b/pubspec.lock index f6ca7fb..b2272a4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -432,14 +432,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" - flutter_sms: + flutter_sms_inbox: dependency: "direct main" description: - name: flutter_sms - sha256: "2fe5f584f02596343557eeca56348f9b82413fefe83a423fab880cdbdf54d8d8" + name: flutter_sms_inbox + sha256: "50580e4265ab65b128777ea3db9b0286ef7beff12df644de42643b26e8b76ee5" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "1.0.4" flutter_svg: dependency: "direct main" description: @@ -1143,14 +1143,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.1" - telephony: - dependency: "direct main" - description: - name: telephony - sha256: "821e074acec3c611e48edadd50a6d9fef864943771a545da7924adcc8509ada9" - url: "https://pub.dev" - source: hosted - version: "0.2.0" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5a7ba3f..30bd70a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,9 +33,8 @@ dependencies: shared_preferences: ^2.5.3 flutter_launcher_icons: ^0.13.1 flutter_native_splash: ^2.3.10 - telephony: ^0.2.0 + flutter_sms_inbox: ^1.0.3 flutter_dotenv: ^5.1.0 - flutter_sms: ^2.3.3 flutter_svg: ^2.1.0 html: ^0.15.6 octo_image: ^2.0.0