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:
JiWoong Sul
2025-07-17 18:30:21 +09:00
parent a8728eb5f3
commit a9a715d67c
9 changed files with 259 additions and 241 deletions

View File

@@ -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<String, MutableList<Map<String, Any>>>()
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<Map<String, Any>>()
// 동일한 발신자와 유사한 금액으로 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<String, Any>? {
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 코드는 제거되었습니다
}