문자만 잡아서는 부족했다
앞 글에서는 "문자가 도착한 그 순간에 조건을 다시 계산한다"는 구조를 잡았습니다. 그 구조를 실제 휴대폰에 올려 며칠 써 보니, 구조는 맞았지만 전제가 틀렸다는 것이 드러났습니다.
전제는 두 가지였습니다. "알림이 필요한 메시지는 SMS 로 온다"와 "나만 쓰면 된다"입니다. 둘 다 사실이 아니었습니다.
그사이 앱 이름은 Fab Alert 로 바뀌었고, 버전은 v1.20 까지 올라왔습니다.
1. RCS 는 SMS 브로드캐스트를 보내지 않는다
가장 먼저 부딪힌 문제입니다. 요즘 안드로이드 기기끼리 주고받는 메시지는 상당수가 RCS(채팅)로 갑니다. RCS 메시지는 SMS_RECEIVED 를 아예 발생시키지 않습니다. 공개된 브로드캐스트 자체가 없으니 SmsReceiver 는 그 메시지가 왔는지 알 방법이 없습니다.
전송 방식과 상관없이 모든 메시징 앱이 반드시 하는 일이 하나 있습니다. 새 메시지가 오면 알림을 띄운다는 것입니다. 그 알림을 읽기로 했습니다.
MessageNotificationListener(NotificationListenerService)가 알림에서 발신자와 본문을 꺼내, 진짜 SMS 와 똑같은 판정 경로로 넘깁니다. 판정 로직은 AlarmTrigger 로 따로 빼서 두 입구가 같은 함수를 부르게 했습니다.
2. 같은 문자에 두 번 울렸다
입구를 하나 더 만들자 곧바로 부작용이 나왔습니다. 평범한 SMS 는 양쪽 모두에 걸립니다. SmsReceiver 가 브로드캐스트로 한 번 받고, 알림 리스너가 메시징 앱의 알림으로 또 한 번 받습니다. 알람이 두 번 울렸고, 전달 문자도 두 통씩 나갔습니다.
AlarmTrigger 에 20초 안에 들어온 같은 본문을 걸러 내는 처리를 넣어 해결했습니다. 같은 본문이 그 안에 다시 들어오면 이미 다른 경로가 처리한 것으로 보고 버립니다.
3. MMS 는 알림이 먼저 오고 내용이 나중에 온다
여기서 가장 오래 붙잡혀 있었습니다. MMS 는 알림이 뜨는 시점과 실제 내용을 통신사에서 내려받는 시점이 다릅니다. 자동 다운로드를 켜 두어도 삼성 메시지는 그사이에 "메시지 크기: 1KB" 같은 임시 문구를 먼저 띄웁니다.
처음에는 본문을 꺼내지 못하면 다시 시도하게 만들었는데, 이것이 통하지 않았습니다. 임시 문구도 엄연히 비어 있지 않은 텍스트이기 때문입니다. 판정 기준이 잘못돼 있었습니다.
다시 확인할지는 텍스트를 찾았는지로 정할 것이 아니라, 아직 키워드에 걸리지 않았는지로 정해야 합니다.
매칭이 없으면 +3초 / +10초 / +25초에 같은 알림의 현재 상태를 다시 읽습니다. 내용 다운로드가 끝났을 때 운영체제가 onNotificationPosted 를 새로 불러 주지 않는 경우가 있어서, 콜백을 기다리지 않고 getActiveNotifications 를 직접 주기적으로 확인하는 것이 유일한 방법이었습니다.
CPU 가 잠들었다
재확인 일정을 짜 놓고도 알람이 늦거나 울리지 않는 경우가 남았습니다. AOD 에서 잠금 화면으로 넘어가는 구간에 CPU 가 깊은 절전에 들어가, delay() 로 잡아 둔 재확인이 예정 시각을 한참 넘겨서야 깨어났습니다.
PARTIAL_WAKE_LOCK 으로 재확인하는 동안 CPU 가 깨어 있게 했습니다. 이것을 전역 플래그 하나로 관리하면, 서로 다른 발신자의 MMS 두 건이 겹칠 때 먼저 끝난 쪽이 아직 기다리는 쪽의 잠금을 풀어 버립니다. 이를 막으려고 알림 키마다 따로 추적하고, 기다리는 키가 하나도 남지 않았을 때만 잠금을 놓게 했습니다.
하나 더: 알림 하나에 메시지 여러 개
메시징 앱은 보통 MessagingStyle 알림을 씁니다. 개별 메시지가 모두 들어 있어서, 여러 통이 한 알림으로 묶여도 하나하나 볼 수 있습니다. 문제는 MMS 가 한꺼번에 도착할 때였습니다. 이때 삼성 메시지가 대화방 단위의 "N개 읽지 않음" 요약만 보내고 개별 본문은 끝내 주지 않는 경우를 보았습니다.
이 때문에 MessagingStyle 이 있어도 일반 title/text 필드를 항상 함께 확인합니다. 어느 쪽이든 최신 내용을 가진 쪽이 매칭될 기회를 얻게 하려는 것입니다. 같은 메시지가 두 번 나올 수 있지만, 그것은 앞서 만든 중복 걸러 내기가 처리합니다.
4. 재난 문자는 아무 데서나 줄을 바꾼다
훨씬 단순하지만 실제로 알람을 놓치게 만든 문제입니다. 경보성 문자는 통신사가 정한 위치에서 제멋대로 줄을 바꿉니다. 한 줄로 읽히는 키워드가 줄바꿈을 사이에 두고 쪼개져 도착하면 매칭에 실패합니다.
비교하기 전에 양쪽 모두 이어진 공백(줄바꿈 포함)을 공백 하나로 합쳐서 해결했습니다. 전화번호도 비슷하게, +82 를 0 으로 바꾸고 뒤 8자리로 비교합니다.
5. "나만 쓰면 된다"가 아니었다
여기서부터 앱의 성격이 바뀝니다. 알람이 저에게만 울려서는 부족했고, 같은 내용을 관련된 사람들에게 바로 전달해야 했습니다.
연락처 탭의 역할이 뒤집혔습니다.
- 전에는 연락처가 감시할 발신자 목록, 곧 매칭 조건이었습니다
- 지금은 연락처가 전달 대상 목록이고, 매칭은 키워드만 봅니다
발신자까지 함께 걸고 싶으면 이제 키워드 항목에 전화번호를 붙입니다. 그 키워드 하나에만 AND 가 걸리고, 번호가 없는 키워드는 발신자를 가리지 않습니다. 조건을 두 목록의 관계로 표현하는 것보다 항목 하나 안에 넣는 편이 훨씬 설명하기 쉬웠습니다.
매칭되면 SmsForwarder 가 [발신자] 본문 형태로 등록된 번호마다 문자를 보냅니다.
전달 실패는 반드시 보이게
문자 전달은 조용히 실패할 구멍이 많습니다. 권한이 없거나, 번호가 비어 있거나, 전파가 잡히지 않을 수 있습니다. 실패하는 경로마다 알림을 띄웁니다. 특히 무선 구간에서 나중에 알려 오는 실패는 sentIntent 를 받는 SmsSendStatusReceiver 를 따로 두어 잡습니다. 그러지 않으면 발송 함수는 성공한 것처럼 끝나고 문자만 사라집니다.
이 알림들은 받는 번호를 기준으로 묶어서, 같은 상대에 대한 즉시 실패와 나중에 알려 온 실패가 알림 두 개로 쌓이지 않고 최신 것 하나로 합쳐지게 했습니다.
6. 결국 인터넷 권한을 넣었다
앞 글에서는 "매니페스트에 INTERNET 이 없다"를 이 앱의 장점으로 적었습니다. 지금은 들어 있는데, 이유는 하나입니다. 스토어를 거치지 않는 앱은 업데이트할 방법이 없습니다.
NAS 에 APK 를 올려 두고 사람마다 받아서 설치하게 하는 방식은 쓰는 사람이 저 혼자일 때만 통했습니다. 고칠 것이 생길 때마다 여러 사람에게 "새 파일을 받아서 다시 설치해 주세요"라고 되풀이할 수는 없었습니다.
앱이 스스로 확인하게 했습니다.
- 비공개 저장소에 올려 둔 작은 JSON 매니페스트를 읽어 버전 코드를 비교합니다
- 확인은 30일에 한 번, 조용히 합니다. 실패해도, 이미 최신이어도 화면에 아무것도 띄우지 않고 새 버전이 실제로 있을 때만 사용자에게 알립니다
- 실패했을 때는 마지막 확인 시각을 갱신하지 않습니다. 일시적인 네트워크 문제 때문에 다음 확인이 30일 뒤로 밀리면 안 되기 때문입니다
- 다운로드는 시스템
DownloadManager에 맡기고(진행률 알림이 따로 만들지 않아도 따라옵니다), 받은 파일은FileProvider로 시스템 설치 화면에 넘깁니다
토큰이 리다이렉트를 따라가서 403
비공개 저장소라 APK 를 받을 때도 토큰이 필요한데, 여기서 한 번 막혔습니다. 릴리스 파일 API 는 서명된 URL 로 302 응답을 돌려줍니다. DownloadManager 는 리다이렉트를 따라갈 때 직접 넣은 헤더를 떼지 않습니다. Authorization 헤더가 서명된 URL 까지 함께 따라가고, 저장소 쪽 서버는 서명과 인증 헤더가 둘 다 있는 요청을 403 으로 거부합니다.
리다이렉트를 직접 한 번 따라가서, 토큰은 그 첫 요청에만 쓰고 DownloadManager 에는 이미 서명된 URL 을 헤더 없이 넘기는 것으로 정리했습니다.
7. 남에게 주려니 필요해진 것들
혼자 쓸 때는 없어도 됐던 것들이, 쓰는 사람이 늘자 하나씩 필요해졌습니다.
권한은 한 번에 하나씩 요청
RECEIVE_SMS 와 SEND_SMS 는 같은 "SMS" 권한 그룹에 속합니다. 일부 제조사 빌드(삼성 One UI 에서 확인)는 같은 그룹의 권한 여러 개를 한꺼번에 요청하면 그룹당 대화상자를 하나만 띄우고, 나머지는 조용히 허용하지 않은 상태로 둡니다. 오류도 안내도 없습니다. 한 번에 하나씩 차례로 요청해서 피했습니다.
기본 벨소리를 앱에 넣기
시스템 기본 알람음에 기대면 기기마다 소리가 제각각입니다. space_bell.ogg 를 앱 리소스로 넣어, 어느 기기에 설치해도 같은 소리가 나게 했습니다.
관리자 잠금
볼륨, 진동, 벨소리처럼 잘못 만지면 알람이 들리지 않게 되는 설정은 설정 안쪽의 관리자 영역으로 옮기고 잠갔습니다. 나머지 화면은 그대로 열어 둡니다.
중복 실행 방지가 영원히 막혀 버리던 버그
AlarmSoundService 는 이미 울리고 있으면 새로 시작하지 않습니다. 벨소리 파일이 깨졌거나 코덱이 실패해서 MediaPlayer 가 재생 도중 오류 상태에 빠지면, 필드는 null 이 아닌 채로 멈춰 버립니다. 그러면 이후의 모든 매칭이 "이미 울리는 중"으로 판정되어 다시는 울리지 않습니다.
OnErrorListener 에서 필드를 비우는 것으로 해결했습니다. "반드시 울려야 하는 앱"에서 가장 무서운 종류의 버그였습니다. 아무 증상 없이 그냥 조용해지기 때문입니다.
정리하면
- "문자"라는 말 하나에 SMS, MMS, RCS 가 모두 들어 있고, 셋은 도착하는 방식이 다릅니다.
- 입구를 하나 더 열면 중복을 걸러 내는 처리가 반드시 함께 필요합니다.
- 다시 시도할지는 "데이터를 얻었는가"보다 "목적을 이뤘는가"로 판정해야 합니다.
- 혼자 쓰던 앱을 남에게 주는 순간, 업데이트 경로가 기능 목록에 들어옵니다.
- 지키던 원칙(
INTERNET없음)을 깰 때는 깬 이유를 함께 적어 둡니다.
In the previous post I settled on a structure where "the conditions are recomputed at the moment the message arrives." After a few days of running it on an actual phone, the structure held up but its premises did not.
There were two premises: "messages that need an alert arrive as SMS" and "I am the only user." Neither was true.
Somewhere along the way the app was renamed Fab Alert and reached v1.20.
1. RCS raises no SMS broadcast
The first wall. A large share of messages between Android phones today go over RCS
(chat). And an RCS message does not raise SMS_RECEIVED at all.
There is no public broadcast for it. Which means SmsReceiver has no way of
knowing the message exists.
There is one thing every messaging app does regardless of transport — it posts a notification for a new message. So I decided to read the notification.
MessageNotificationListener (a NotificationListenerService)
pulls the sender and body out of the notification and hands them to
exactly the same decision path as a real SMS. The matching logic moved
into AlarmTrigger so both entry points call the same function.
2. The same message rang twice
The immediate side effect of adding a second entry point. An ordinary SMS trips
both — once through SmsReceiver's broadcast, and again
through the messaging app's notification. The alarm fired twice, and two forwarded texts
went out.
Fixed by adding body-level deduplication over a 20-second window in
AlarmTrigger. If the same body arrives again inside that window, it is
treated as already handled by the other path and dropped.
3. With MMS the notification arrives before the content
This is where I got stuck longest. For MMS, the moment the notification appears and the moment the actual content finishes downloading from the carrier are different. Even with auto-download on, Samsung Messages shows a placeholder in between — something like "Message size: 1KB."
My first version was "retry if body extraction fails," and it did not work. The placeholder is perfectly non-empty text. The criterion was wrong.
The thing that decides whether to look again is not "did I find text?" but "has it still not matched a keyword?"
So when there is no match, it re-reads the current state of the same notification at
+3s / +10s / +25s. The OS sometimes does not call
onNotificationPosted again when the content finishes downloading, so polling
getActiveNotifications directly — rather than waiting for the callback — was
the only way.
And then the CPU fell asleep
Even with the recheck schedule in place, alarms were still late or missing. During the
transition from always-on display to lock screen, the CPU enters deep sleep, and the
delay()-based rechecks woke up long past their scheduled time.
A PARTIAL_WAKE_LOCK keeps it awake for the duration of the polling. But
managing that with a single global flag means that when two MMS from different senders
overlap, whichever finishes first releases the lock the other one is still waiting on. So
the lock is tracked per notification key and only released when no key is
still pending.
Aside: several messages in one notification
Messaging apps generally use MessagingStyle notifications, which contain
the individual messages, so several bundled into one notification are still separately
readable. But when MMS arrive in a burst, Samsung Messages was observed emitting only a
thread-level "N unread" summary and never providing the individual
bodies.
So the plain title/text fields are always checked as well, even when
MessagingStyle is present — whichever side has the newest content gets its
chance to match. The same message may surface twice, but the deduplication already built
handles that.
4. Emergency alerts break lines anywhere they like
A much simpler problem, and one that actually caused missed alarms. Alert-type messages get line breaks inserted by the carrier wherever it pleases. If a keyword that reads as one phrase arrives split across a newline, the match fails.
Fixed by collapsing runs of whitespace (newlines included) to a single
space on both sides before comparing. Phone numbers get similar treatment:
+82 is folded to 0 and comparison uses the last 8 digits.
5. "I am the only user" was not true
This is where the app changed character. An alarm ringing only for me was not enough — the same content had to reach the relevant people immediately.
Which inverted the role of the Contacts tab.
- Before — contacts = the list of senders to watch (a matching condition)
- Now — contacts = the list of forwarding targets. Matching looks only at keywords
To also constrain the sender, you now attach a phone number to a keyword entry. The AND applies to that one keyword only, and keywords without a number do not filter by sender. Expressing a condition as something inside a single entry turned out to be far easier to explain than as a relationship between two lists.
On a match, SmsForwarder sends a text in the form
[sender] body to each registered number.
Forwarding failures must be visible
Text forwarding has many ways to fail quietly: no permission, an empty number, no
signal. So every failure path posts a notification. In particular, the
asynchronous failures at the radio layer are caught by a dedicated
SmsSendStatusReceiver receiving the sentIntent — otherwise the
send function returns as if it succeeded and the message simply vanishes.
These notifications are keyed by recipient number, so an immediate failure and an asynchronous failure for the same recipient collapse into one current notification rather than stacking into two.
6. The internet permission went in after all
In the previous post I listed "no INTERNET in the manifest" as something
this app was proud of. It is there now. For one reason —
an app that does not go through a store has no way to update.
Putting the APK on a NAS and having each person download and install it works only while I am the sole user. I could not repeat "please download the new file and reinstall" to several people every time something needed fixing.
So the app checks for itself.
- Reads a small JSON manifest in a private repository and compares version codes
- Once every 30 days, and silently — nothing appears on failure, nothing appears when up to date. The user sees it only when a new version actually exists
- A failure does not update the last-checked timestamp. A transient network problem must not push the next check 30 days out
- Downloading is delegated to the system
DownloadManager(progress notifications come free), and the file is handed to the system installer viaFileProvider
The token followed the redirect, and got a 403
Because the repository is private, downloading the APK needs the token too — and that
is where it stalled. The release asset API returns a 302 to a signed URL. But
DownloadManager does not strip custom headers when following a
redirect. So the Authorization header travels along to the signed
URL, and the storage backend rejects a request carrying
both a signature and an auth header with a 403.
Resolved by following the redirect manually, once: the token is used
only for that first request, and DownloadManager gets the already-signed URL
with no headers.
7. Things only needed once you give it to someone else
Things that were fine to skip while using it alone became necessary one by one as the user count grew.
Requesting permissions one at a time
RECEIVE_SMS and SEND_SMS belong to the same "SMS" permission
group. Some OEM builds (observed on Samsung One UI), when asked for several permissions
from one group at once, show a dialog for only one and silently leave the rest
ungranted. No error, no notice. Worked around by requesting them sequentially,
one at a time.
Bundling the default ringtone
Relying on the system default alarm sound means a different sound on every device.
space_bell.ogg ships as an app resource so it sounds the same wherever it is
installed.
Admin lock
Settings that make the alarm inaudible if mis-set — volume, vibration, ringtone — moved into a locked admin area inside settings. Every other screen stays open.
The re-entrancy guard that could latch forever
AlarmSoundService does not start again if it is already ringing. But when
MediaPlayer falls into an error state mid-playback — a corrupt ringtone file,
a codec failure — the field is left non-null and dead. Every subsequent match
then reads as "already ringing," and it never rings again.
Fixed by clearing the field in OnErrorListener. For an app whose whole job
is to ring, this is the scariest class of bug — no symptom at all, it just goes quiet.
In short
- The single word "text" covers SMS, MMS, and RCS, and all three arrive differently.
- Open a second entry point and deduplication necessarily follows.
- A retry criterion should be "did I achieve the goal?" not "did I get data?"
- The moment you hand something you built for yourself to someone else, an update path joins the feature list.
- When you break a principle you had been keeping (no
INTERNET), write down why you broke it.