[Data 수집] SMS, MMS, RCS, Notificaiton - Android 편

jun·2025년 5월 7일

Data Engineer

목록 보기
1/1
post-thumbnail

개요

오늘은 데이터 수집을 위해 안드로이드에서 제공하는 SMS, MMS, RCS, Notification의 개념을 정리하고,
각 메시지 타입의 데이터 수집 방법, 실제 필드 파싱, 그리고 처리 과정에서 발생한 문제와 해결 방안을 공유하고자 합니다.

메시지를 수신하고 데이터를 추출하는 작업은 겉보기에는 단순해 보일 수 있지만,
실제로는 다양한 이슈와 시행착오가 존재했습니다.
이 글은 그런 과정 하나하나를 기록하기 위한 첫걸음입니다.

저는 앞으로 데이터 수집 → 변형 → 적재 → 분석 → AI에 이르는 과정을 직접 다루며,
데이터 엔지니어로서의 여정을 기술 블로그를 통해 정리해나가고자 합니다.

메시지 타입 및 Notification 정의

유형설명주요 특징
SMS
(Short Message Service)
160자 이하의 단문 텍스트 메시지- 인터넷 없이 통신망으로 전송
- 단일 텍스트로 구조 단순
- BroadcastReceiver로 수신 가능
- context.getContentResolver()로 과거 데이터 조회
MMS
(Multimedia Messaging Service)
이미지, 영상, 오디오 등 멀티미디어 메시지- 멀티파트 메시지 구조
- 첨부파일 포함 가능
- Broadcast 수신 불가 → ContentProvider에서 폴링 필요
RCS
(Rich Communication Services)
인터넷 기반 고급 메시징 프로토콜- 읽음 확인, 타이핑 표시 등 채팅 기능
- 고화질 미디어 전송
- 통신사/구글 메시지 앱 기반
Notification
(알림 시스템)
앱에서 발생한 이벤트를 사용자에게 알리는 UI 요소- 모든 앱에서 사용 가능
- NotificationListenerService로 수신
- 제목/본문 등 UI 기반 정보 추출
- 앱마다 포맷 상이하여 파싱 필요

데이터 추출

공통

data class Sms(
    val id: String,
    val message: String,
    val sender: String,
    val displaySender: String,
    val date: String,
    val type: SmsType
)

enum class SmsType {
    SMS, MMS, RCS, Notification
}

object Utils {
    fun getConvertedDate(timestampMillis: Long): String {
        val date = Date(timestampMillis)
        val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
       
        return sdf.format(date)
    }
    
    fun generateHashId(input: String): String {
        val bytes = MessageDigest.getInstance("SHA-256")
        .digest(input.toByteArray(Charsets.UTF_8))

        return bytes.joinToString("") { "%02x".format(it) }
    }
}

SMS

실시간 수신

Receiver

// BroadcastReceiver를 상속하여 SMS 수신 이벤트를 감지하는 클래스
class SmsCatcher : BroadcastReceiver() {

    override fun onReceive(context: Context, intent: Intent) {
        // 브로드캐스트로부터 전달된 데이터(Bundle)를 가져옵니다
        val bundle = intent.extras ?: return

        // SMS 메시지는 "pdus"라는 이름의 배열로 전달됩니다
        val pdus = bundle.get("pdus") as? Array<*> ?: return

        // Android 6.0(M) 이상에서는 format이 필요합니다
        val format = bundle.getString("format")

        // 메시지 본문, 발신자, 표시용 발신자, 타임스탬프 초기화
        var messageBody = ""
        var sender = ""
        var displaySender = ""
        var timestamp = 0L

        // pdus 배열을 순회하면서 SmsMessage 객체로 변환 및 정보 추출
        pdus.mapNotNull { it as? ByteArray }           // ByteArray로 캐스팅
            .map { pdu ->
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                    SmsMessage.createFromPdu(pdu, format)
                } else {
                    SmsMessage.createFromPdu(pdu)
                }
            }
            .forEach { sms ->
                // 다중 메시지를 하나로 이어붙이기
                messageBody += sms.messageBody.orEmpty()

                // 발신자 번호 추출
                sender = sms.originatingAddress.orEmpty()
                displaySender = sms.displayOriginatingAddress.orEmpty()

                // 타임스탬프는 첫 번째 SMS 기준으로만 설정
                if (timestamp == 0L) timestamp = sms.timestampMillis
            }

        // 최종 SMS 객체 생성 (사용자 정의 모델에 맞게 매핑)
        val sms = SMS(
            id = Utils.generateHashId(messageBody + sender + date),
            msg = messageBody,
            sender = sender,
            displaySender = displaySender,
            date = Utils.getConvertedDate(timestamp),
            type = Constants.SmsType.SMS_TYPE_SMS
        )

        // 디버깅 로그 출력 (실제로는 저장, 파싱 등 처리)
        Log.d("SMS_SIMPLE", sms.toString())
    }
}

Reciever 등록

<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />

<receiver android:name=".SmsCatcher" android:exported="true">
    <intent-filter>
        <action android:name="android.provider.Telephony.SMS_RECEIVED" />
    </intent-filter>
</receiver>

과거 데이터 조회

import android.content.Context
import android.net.Uri
import android.provider.Telephony
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.*

fun getRecentSmsList(context: Context): List<Sms> {
    val results = mutableListOf<Sms>()

    // 안드로이드의 SMS inbox URI
    val uri = Uri.parse("content://sms/inbox")

    // 현재 시간 (밀리초 기준)
    val endTime = System.currentTimeMillis()

    // 3개월 전의 타임스탬프 계산
    val startTime = Calendar.getInstance().apply {
        add(Calendar.MONTH, -3)
    }.timeInMillis

    // 날짜 조건 설정: 최근 3개월간 SMS만 조회
    val where = "date BETWEEN ? AND ?"
    val whereArgs = arrayOf(startTime.toString(), endTime.toString())

    // 날짜 기준 오름차순 정렬
    val sortOrder = "date ASC"

    // ContentResolver로 쿼리 실행
    val cursor = context.contentResolver.query(uri, null, where, whereArgs, sortOrder)

    cursor?.use {
        // 필요한 컬럼 인덱스 정의
        val bodyIdx = it.getColumnIndexOrThrow("body")
        val addressIdx = it.getColumnIndexOrThrow("address")
        val dateIdx = it.getColumnIndexOrThrow("date")

        // 커서를 순회하며 SMS 항목 추출
        while (it.moveToNext()) {
            val body = it.getString(bodyIdx)               // 메시지 본문
            val address = it.getString(addressIdx)         // 발신자 번호
            val timestamp = it.getLong(dateIdx)            // 수신 일시 (ms)
            val dateString = Utils.getConvertedDate(timestamp)   // 사람이 읽을 수 있는 날짜 포맷으로 변환

            // 메시지 본문 + 발신자 + 날짜 조합으로 고유 ID 생성
            val hashId = Utils.generateHashId(body + address + dateString)

            // SMS 데이터 클래스 인스턴스 생성
            val sms = Sms(
                id = hashId,
                message = body,
                sender = address,
                displaySender = address,
                date = dateString,
                type = SmsType.SMS
            )

            results.add(sms)
        }
    }

    return results
}

MMS

실시간 수신

class MmsCatcher : BroadcastReceiver() {

    companion object {
        const val MMS_ACTION = "android.provider.Telephony.WAP_PUSH_RECEIVED"
    }

    private var mmsHelper: MmsHelper? = null

    override fun onReceive(context: Context, intent: Intent) {
        // MMS는 수신 후 inbox에 저장되기까지 딜레이가 있어 지연 처리
        Handler(Looper.getMainLooper()).postDelayed({
            try {
                // MMSHelper를 통해 최근 수신된 MMS 데이터를 가져옵니다
                val helper = mmsHelper ?: MmsHelper(context).also { mmsHelper = it }
                val mms = helper.getMms()

                // 추출한 MMS 로그 출력 (또는 리스트에 저장 등으로 확장 가능)
                Log.d("MMS_CATCHER", "MMS received: $mms")

            } catch (e: Exception) {
                Log.e("MMS_CATCHER", "Error reading MMS", e)
            }
        }, 3000) // inbox에 완전히 저장되기 위한 지연 (필요 시 조절 가능)
    }
}

class MmsHelper(private val context: Context) {

    /**
     * 가장 최근 수신된 MMS를 읽어와서 SMS 형태로 반환합니다.
     * 내부 예외 발생 시 null 반환.
     */
    fun getMms(): Sms? = try {
        val cursor = context.contentResolver.query(
            Uri.parse("content://mms/inbox"),
            null,
            "msg_box = 1",
            null,
            "_id DESC LIMIT 1"
        ) ?: return null

        cursor.use {
            if (!it.moveToFirst()) return null

            val id = it.getInt(it.getColumnIndexOrThrow("_id"))
            val timestamp = it.getLong(it.getColumnIndexOrThrow("date")) * 1000
            val sender = getMmsAddress(id)
            val body = parseMmsBody(id.toString()).take(490)

            Sms(
                id = generateHashId(body + sender + timestamp.toString()),
                message = body,
                sender = sender,
                displaySender = sender,
                date = Utils.getConvertedDate(timestamp),
                type = SmsType.MMS
            )
        }
    } catch (e: Exception) {
        null
    }

    /** 발신자 번호를 content://mms/{id}/addr 에서 추출 */
    private fun getMmsAddress(messageId: Int): String {
        val uri = Uri.parse("content://mms/$messageId/addr")
        val cursor = context.contentResolver.query(uri, arrayOf("address"), "msg_id=$messageId", null, null)
            ?: return ""

        cursor.use {
            while (it.moveToNext()) {
                val raw = it.getString(it.getColumnIndexOrThrow("address"))
                if (!raw.isNullOrBlank()) {
                    return raw.replace("-", "")
                }
            }
        }
        return ""
    }

    /** MMS 본문 파트에서 텍스트 추출 */
    private fun parseMmsBody(messageId: String): String {
        val uri = Uri.parse("content://mms/part")
        val cursor = context.contentResolver.query(uri, null, null, null, null) ?: return ""

        cursor.use {
            while (it.moveToNext()) {
                val mid = it.getString(it.getColumnIndexOrThrow("mid"))
                if (mid != messageId) continue

                val type = it.getString(it.getColumnIndexOrThrow("ct"))
                if (type != "text/plain") continue

                val data = it.getString(it.getColumnIndexOrThrow("_data"))
                return if (!data.isNullOrBlank()) {
                    readMmsText(it.getString(it.getColumnIndexOrThrow("_id")))
                } else {
                    it.getString(it.getColumnIndexOrThrow("text")).orEmpty()
                }
            }
        }
        return ""
    }

    /** _data가 있는 경우, InputStream을 통해 텍스트 추출 */
    private fun readMmsText(partId: String): String {
        val uri = Uri.parse("content://mms/part/$partId")
        val builder = StringBuilder()

        context.contentResolver.openInputStream(uri)?.use { input ->
            BufferedReader(InputStreamReader(input, Charsets.UTF_8)).use { reader ->
                var line = reader.readLine()
                while (line != null) {
                    builder.append(line)
                    line = reader.readLine()
                }
            }
        }

        return builder.toString()
    }
}

과거 데이터 조회

MMS는 content://mms/inbox를 통해 과거 데이터를 조회할 수 있지만, 쿼리 성능이 매우 느리고, 기기/OS 버전에 따라 저장 구조도 다릅니다.
따라서 MMS가 필수 데이터가 아닌 경우, 과거 MMS 조회는 스킵하거나 제한된 범위로 필터링하는 것을 권장합니다.

fun getRecentMmsAsSmsList(cnt: Int): List<Sms> {
    val smsList = mutableListOf<Sms>()
    var cursor: Cursor? = null

    try {
        val uri = Uri.parse("content://mms/inbox")
        val sortOrder = "date ASC LIMIT $cnt"

        cursor = context.contentResolver.query(uri, null, "msg_box=1", null, sortOrder)

        cursor?.use {
            val idIndex = it.getColumnIndexOrThrow("_id")
            val dateIndex = it.getColumnIndexOrThrow("date")

            while (it.moveToNext()) {
                val id = it.getInt(idIndex)
                val timestamp = it.getLong(dateIndex) * 1000
                val dateString = Utils.getConvertedDate(timestamp)
                val sender = mmsHelper.getMmsAddress(id)
                val body = mmsHelper.parseMmsBody(id.toString()).take(490)

                val hashId = Utils.generateHashId(body + sender + dateString)

                val sms = Sms(
                    id = hashId,
                    message = body,
                    sender = sender,
                    displaySender = sender,
                    date = dateString,
                    type = SmsType.MMS
                )

                smsList.add(sms)
            }
        }
    } catch (e: Exception) {
        Log.e("MMS_PARSE", "Error parsing MMS to Sms", e)
    } finally {
        cursor?.close()
    }

    return smsList
}

RCS

실시간 수신

class RcsCatchReceiver : BroadcastReceiver() {

    private var rcsService: RcsService? = null

    override fun onReceive(context: Context?, intent: Intent?) {
        // context 또는 intent가 null이면 바로 종료
        if (context == null || intent == null) return
        if (intent.action != RCS_RECEIVED_ACTION) return

        try {
            // msg_id 는 RCS 메시지를 식별하는 고유 키
            val msgId = intent.extras?.get("msg_id")?.toString() ?: return

            // RcsService 초기화 후 메시지 조회 및 처리
            val service = rcsService ?: RcsService(context).also { rcsService = it }
            val sms = service.queryRcs(msgId)

            sms?.let {
                Log.d("RCS_CATCH", "RCS message received: $it")
                // 이후 처리 (파싱, 저장 등) 필요 시 확장 가능
            }

        } catch (e: Exception) {
            Log.e("RCS_CATCH", "Error handling RCS message", e)
        }
    }

    companion object {
        const val RCS_RECEIVED_ACTION = "com.services.rcs.MESSAGE_RECEIVED"
    }
}

과거 데이터 조회


    /**
     * RCS 전체 메시지를 조건(where) 기반으로 조회
     */
    fun getRcsList(where: String? = null, cnt: Int = Int.MAX_VALUE): List<SMS> {
        val results = mutableListOf<SMS>()
        val uri = "content://im/rcs_read_im".toUri()
        val sort = "date ASC LIMIT $cnt"

        context.contentResolver.query(uri, null, where, null, sort)?.use { cursor ->
            while (cursor.moveToNext()) {
                RcsParser.parseRcs(cursor)?.let { results.add(it) }
            }
        }

        return results
    }

Notification

실시간 수신

import android.app.Notification
import android.content.Intent
import android.provider.Telephony
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.*

class NotificationCatchService : NotificationListenerService() {

    override fun onNotificationPosted(sbn: StatusBarNotification?) {
        sbn ?: return
        val notification = sbn.notification ?: return
        val extras = notification.extras ?: return

        val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty()
        val content = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString().orEmpty()
        val bigText = extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString().orEmpty()
        val packageName = sbn.packageName.orEmpty()
        val timestamp = notification.`when`
        val dateString = Utils.getConvertedDate(timestamp)

        // Sms 객체로 직접 변환
        val sms = Sms(
            id = Utils.generateHashId(title + content + dateString),
            message = if (bigText.isNotEmpty()) bigText else content,
            sender = packageName,
            displaySender = title,
            date = dateString,
            type = SmsType.Notification
        )

        Log.d("NOTI_CATCH", "Parsed SMS: $sms")
    }


    override fun onNotificationRemoved(sbn: StatusBarNotification?) {
        // 필요시 구현
    }

    override fun onListenerConnected() {
        super.onListenerConnected()
        Log.d("NOTI_CATCH", "Listener connected")
    }

    override fun onListenerDisconnected() {
        super.onListenerDisconnected()
        Log.d("NOTI_CATCH", "Listener disconnected")
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        return START_STICKY
    }
}

과거 데이터 조회

불가능

데이터 처리 문제

문제 1: 문자와 알림의 중복 수신 문제

현상:
하나의 문자 수신 이벤트가 실제 문자(SMS_RECEIVED)와 동시에 알림(Notification)으로도 발생 →
동일한 메시지가 두 번 수신되어 파싱/저장이 중복됨

원인:
안드로이드 시스템이 문자 수신 시, 알림 채널을 통해 별도의 Notification을 발송하기 때문

해결 방법:

  • 알림에서 수신된 메시지의 패키지명이 기본 SMS 앱인지 확인하고, 해당 앱에서 발생한 알림은 필터링
  • hashId를 통한 중복 처리

문제 2: 같은 결제, 다른 출처로 중복 수신되는 문제

현상:
예를 들어 신한은행, 삼성페이 등 하나의 결제에 대해
문자 + 금융앱 알림 + 삼성페이 알림 등 3중 알림/문자 수신 발생 →
동일 금액이 여러 번 인식되는 문제

원인:
다양한 채널이 하나의 결제에 대해 각각 독립적으로 알림/문자를 발송함

해결 방법:

  • 금액 + 날짜 + 발신처 기반의 고유 해시 ID 생성 후, 중복 제거
  • 수신되는 시간의 3분 정도 여유를 두어 금액, 키워드 비교 진행
  • 키워드의 경우 중복 데이터로 판단될 경우 키워드가 긴경우로 replace 진행

문제 3: 알림이 동일 데이터인데도 중복 수신되는 문제

현상:
같은 알림 내용이 반복 수신됨. StatusBarNotification.postTime이 다르게 설정되어 있어
내용은 동일하지만 중복으로 인식되지 않음

원인:
알림 시스템이 앱 상태, 갱신 주기 등에 따라 동일한 알림을 다시 push함
(postTime이 매번 다르게 들어옴)

해결 방법:

  • title + content + packageName + message 기반의 중복 로직 체크
  • postTime 대신 실제 내용 기준 비교

문제 4: 알림 내 필드별 데이터 위치가 다름

현상:
금액, 키워드 등이 알림의 title, text, bigText, subText 등 다양한 필드에 분산되어 있어 파싱 어려움

해결 방법:

  • 파서에서 모든 필드를 한 메시지로 concat 후 패턴 매칭 또는,
    field → 역할 기준으로 분리하여 다중 파싱 규칙 적용

결론

안드로이드는 SMS, MMS, RCS, Notification을 통해 개인에게 전달되는 다양한 메시지를 수신하고 저장할 수 있는 강력한 수단을 제공합니다.
이러한 메시지를 자동으로 수집하고 정제하여 내 삶의 결제/소비/이벤트 흐름을 데이터화할 수 있습니다.

저는 이 과정을 통해 수집 → 변형 → 적재 → 전처리 → 분석 → 개인화된 AI 모델 생성까지 이어지는
엔드 투 엔드 데이터 파이프라인을 직접 구축하고, 그 여정을 블로그를 통해 기록하고자 합니다.

작은 수신 메시지 하나가,
나만의 AI model 을 누구나 만들수 있도록 한발 한발 나아가 보겠습니다.

감사합니다.

profile
사람들에게 긍정적 에너지와 즐거움을 주는 개발자

0개의 댓글