Android에서 SMS를 수신하여 서버로 중계하는 앱을 만들었다. SMS는 잘 동작했는데, MMS 지원을 추가하면서 심각한 버그가 발생했다.
Table of contents
Open Table of contents
잘못된 접근
SMS 수신 코드가 잘 동작하니까, MMS도 비슷하게 하면 되겠다고 생각했다.
// SMS 수신 — 정상 동작
if ("android.provider.Telephony.SMS_RECEIVED".equals(action)) {
Bundle bundle = intent.getExtras();
Object[] pdus = (Object[]) bundle.get("pdus");
for (Object pdu : pdus) {
SmsMessage msg = SmsMessage.createFromPdu((byte[]) pdu, format);
// msg.getOriginatingAddress(), msg.getMessageBody() 사용
}
}
// MMS 수신 — 잘못된 코드
if ("android.provider.Telephony.MMS_RECEIVED".equals(action)) {
// SMS와 동일한 PDU 파싱 시도
Bundle bundle = intent.getExtras();
Object[] pdus = (Object[]) bundle.get("pdus");
// pdus가 null이거나, 파싱하면 깨진 데이터가 나옴
}
문제 1: MMS_RECEIVED는 표준 intent가 아니다
android.provider.Telephony.MMS_RECEIVED는 공식 Android API에 존재하지 않는다. 일부 기기에서 발생할 수 있지만, 보장되지 않는다. 실제로 MMS는 WAP Push 메시지로 전달된다.
문제 2: MMS는 PDU 구조가 다르다
SMS와 MMS는 완전히 다른 프로토콜이다.
| 항목 | SMS | MMS |
|---|---|---|
| 전달 방식 | 직접 PDU | WAP Push 알림 → 다운로드 |
| Intent | SMS_RECEIVED | WAP_PUSH_RECEIVED |
| 데이터 | extras의 “pdus” 배열 | ContentProvider에 저장 |
| 파싱 | SmsMessage.createFromPdu() | ContentResolver 쿼리 |
SMS의 PDU 파싱 로직을 MMS에 적용하면, pdus가 null이거나 잘못된 바이트를 파싱하여 깨진 문자열이 서버로 전송되는 silent data corruption이 발생한다. 에러가 나지 않고 잘못된 데이터가 전달되니 발견이 늦었다.
올바른 MMS 수신 방법
Manifest 설정
<receiver
android:name=".MmsReceiveBroadcast"
android:exported="true"
android:permission="android.permission.BROADCAST_WAP_PUSH">
<intent-filter android:priority="1000">
<action android:name="android.provider.Telephony.WAP_PUSH_RECEIVED" />
<data android:mimeType="application/vnd.wap.mms-message" />
</intent-filter>
</receiver>
핵심:
WAP_PUSH_RECEIVEDintent 사용 (MMS_RECEIVED 아님)mimeType필터로 MMS만 선별BROADCAST_WAP_PUSH권한 필수
BroadcastReceiver 구현
MMS는 WAP Push 수신 시점에 아직 ContentProvider에 저장되지 않았을 수 있다. 약간의 딜레이가 필요하다.
public class MmsReceiveBroadcast extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (!"android.provider.Telephony.WAP_PUSH_RECEIVED".equals(intent.getAction())) return;
PendingResult pendingResult = goAsync();
executor.execute(() -> {
try {
// MMS가 ContentProvider에 저장될 때까지 대기
Thread.sleep(3000);
// 최신 MMS ID 조회
String mmsId = getLatestMmsId(context);
if (mmsId == null) return;
// 발신자 추출
String sender = getMmsSender(context, mmsId);
// 본문 추출
String body = getMmsBody(context, mmsId);
// 서버로 전송
RelayClient.send(sender, body);
} finally {
pendingResult.finish();
}
});
}
}
ContentResolver로 MMS 데이터 추출
// 최신 MMS ID 조회
private String getLatestMmsId(Context context) {
try (Cursor cursor = context.getContentResolver().query(
Uri.parse("content://mms"),
new String[]{"_id"},
null, null, "_id DESC LIMIT 1")) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getString(0);
}
}
return null;
}
// 발신자 추출 (addr 테이블, type=137=FROM)
private String getMmsSender(Context context, String mmsId) {
try (Cursor cursor = context.getContentResolver().query(
Uri.parse("content://mms/" + mmsId + "/addr"),
new String[]{"address"},
"type=137", null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getString(0);
}
}
return null;
}
// 본문 추출 (part 테이블, text/plain)
private String getMmsBody(Context context, String mmsId) {
try (Cursor cursor = context.getContentResolver().query(
Uri.parse("content://mms/part"),
new String[]{"_id", "ct", "text", "_data"},
"mid=" + mmsId + " AND ct='text/plain'", null, null)) {
if (cursor != null && cursor.moveToFirst()) {
String text = cursor.getString(cursor.getColumnIndex("text"));
if (text != null) return text;
// text 필드가 null이면 _data 경로에서 읽기
String dataPath = cursor.getString(cursor.getColumnIndex("_data"));
if (dataPath != null) {
return readFromContentResolver(context, cursor.getString(0));
}
}
}
return null;
}
리네이밍 시 주의사항
앱 패키지명을 변경할 때 HTTP 헤더도 함께 변경해야 한다. 서버에서 인증 헤더로 앱을 식별하는 경우, 헤더명 변경이 서버와 동기화되지 않으면 401 에러가 발생한다.
// Before
connection.setRequestProperty("X-MyApp-OldName-Key", apiKey);
// After — 서버 측도 동시에 변경해야 함
connection.setRequestProperty("X-MyApp-NewName-Key", apiKey);
핵심 정리
- MMS는 SMS와 완전히 다른 프로토콜이다 — PDU 파싱을 공유할 수 없다
- MMS_RECEIVED intent는 비표준이다 —
WAP_PUSH_RECEIVED를 사용해야 한다 - MMS 데이터는 ContentProvider에서 쿼리한다 — extras에서 직접 추출할 수 없다
- 3초 딜레이가 필요하다 — WAP Push 수신 시점에 MMS가 아직 저장되지 않았을 수 있다
- silent data corruption이 가장 위험하다 — 에러 없이 잘못된 데이터가 전달되면 발견이 늦다
참고 자료
- Android SmsMessage API — Android 공식 SmsMessage 클래스 문서
- WapPushOverSms.java (AOSP) — MMS PDU 내부 처리 소스