← 제작기

앱이 꺼져 있어도 반드시 울리게 만들기

뒷이야기: 이 글을 쓴 뒤 앱 이름이 Fab Alert 로 바뀌었고, RCS·MMS 수신과 문자 전달, 자체 업데이트가 붙으면서 구조가 꽤 달라졌습니다. 아래 본문은 글을 쓴 시점을 기준으로 하고, 지금과 달라진 부분은 절마다 표시해 두었습니다. 이어지는 이야기는 문자만 잡아서는 부족했다에 있습니다.

필요한 기능은 단순했습니다. 정해 둔 번호나 키워드가 담긴 문자가 오면, 정해 둔 요일과 시간대에는 무음이든 방해 금지 모드든 무시하고 크게 울리는 것입니다. 재난 문자가 하는 동작을 제가 정한 조건으로 하게 만드는 셈입니다.

기능은 단순해도 요구 조건은 까다롭습니다. 반드시 울려야 하기 때문입니다. 한 번이라도 울리지 않으면 이 앱은 있을 이유가 없어집니다.

처음 떠올린 구조가 위험한 이유

직관적인 설계는 이렇습니다. 감시 시간대가 되면 포그라운드 서비스를 켜고, 그 서비스가 살아 있는 동안 들어오는 문자를 처리합니다.

이 구조에서는 알람이 울릴지가 서비스가 지금 떠 있는지에 달려 있습니다. 안드로이드에서 그것은 보장되는 조건이 아닙니다. 제조사의 배터리 최적화나 메모리 부족, 조금 늦게 도는 스케줄 타이머가 언제든 끼어들 수 있습니다. 어느 하나에만 걸려도 알람은 조용히 울리지 않고, 울리지 않았다는 사실은 나중에야 알게 됩니다.

판단 시점을 옮기다

바꾼 원칙: 알람을 울릴지는 문자가 도착한 그 순간에 그 순간의 시각으로 다시 계산합니다. 다른 무엇이 떠 있는지는 보지 않습니다.

매니페스트에 등록한 SmsReceiver 는 앱이 완전히 꺼져 있어도 문자가 오면 호출됩니다. 여기서 매번 ScheduleUtil.isActiveNow() 를 직접 불러 "지금이 설정한 감시 시간대인가"를 확인한 뒤에만 다음 단계로 넘어갑니다.

기존의 포그라운드 서비스는 없애지 않고 역할을 낮췄습니다. 이제 MonitorForegroundService 는 "감시 중"이라는 상태를 사용자에게 보여 주는 표시용이고, 1분 간격의 AlarmManager 타이머로 스케줄을 다시 따져 스스로 켜지고 꺼집니다. 이 서비스가 늦거나 꺼져 있어도 실제 알람 동작에는 영향이 없습니다.

실제로 크게 울리게 하는 부분

조건이 맞으면 AlarmSoundService 가 세 가지를 동시에 합니다.

  • STREAM_ALARM 으로 소리를 반복 재생합니다. 무음이나 방해 금지 모드에서도 재생되는 스트림이고, 시스템 알람 앱이 쓰는 것과 같은 방식이라 편법이 아닙니다.
  • 진동을 울립니다.
  • fullScreenIntent 알림으로 AlarmActivity 를 잠금 화면 위에 띄웁니다.

이 화면은 중지 버튼으로만 꺼집니다. 뒤로 가기로 실수로 닫힐 수 있다면 울린 것과 울리지 않은 것의 차이가 없어지기 때문입니다.

빈 설정은 "전체 허용"이 아니다

MatchEngine 에서 한 번 더 고민한 부분입니다. 연락처 목록과 키워드 목록이 둘 다 비어 있으면 어떤 문자도 걸지 않습니다.

  • 한쪽만 등록 → 그 조건만 봅니다
  • 둘 다 등록 → 두 조건을 모두 만족해야 합니다(AND)
  • 둘 다 비어 있음 → 아무것도 걸지 않습니다

조건이 없으니 모두 통과시키도록 만들면, 앱을 처음 켠 사람이 설정을 마치기도 전에 모든 문자에 알람이 울립니다. 기본값은 조용한 쪽이어야 합니다.

지금은 다릅니다. 연락처는 더 이상 매칭 조건이 아니고 전달 대상이 됐으며, 매칭은 키워드만 봅니다. 발신자까지 함께 걸고 싶으면 키워드 항목에 전화번호를 붙여 그 항목에만 AND 를 겁니다. "빈 목록은 아무것도 걸지 않는다"는 규칙은 그대로입니다.

인터넷 권한을 아예 넣지 않았다

이 앱은 기기에 도착하는 모든 문자의 발신 번호와 내용을 읽기 때문에, 매니페스트에 INTERNET 권한을 아예 넣지 않았습니다. 외부로 보내지 않는다는 것이 코드의 약속에 그치지 않고 매니페스트가 강제하는 사실이 됩니다. 저장은 전부 기기 안의 Room DB 와 DataStore 에서만 일어납니다.

지금은 다릅니다. 자체 업데이트 기능을 붙이면서 INTERNET 권한을 넣을 수밖에 없었습니다. 스토어를 거치지 않는 앱은 스스로 업데이트를 받아 오는 것 말고는 방법이 없기 때문입니다. 매니페스트가 강제하던 보장이 다시 코드의 약속으로 내려온 셈이어서, 이 결정은 다음 글에서 따로 정리했습니다.
이 앱은 반드시 본인 소유 기기, 또는 기기 소유자의 분명한 동의를 받은 기기에만 설치해야 합니다. 다른 사람의 동의 없이 문자메시지를 열람하면 통신비밀보호법 등에 저촉될 수 있습니다. README 맨 앞에도 같은 문장을 넣어 두었습니다.

배포는 NAS 를 통한 직접 설치로

플레이스토어에 올릴 성격의 앱이 아니어서, 직접 서명한 릴리스 APK 를 만들어 NAS 파일 공유에 올려 두고 휴대폰에서 받아 설치하는 방식을 택했습니다.

./gradlew assembleRelease
# → app/build/outputs/apk/release/app-release.apk

스토어를 거치지 않으니 설치한 뒤 권한을 전부 손으로 켜야 합니다. 앱 안에 권한 탭을 따로 만들어 필요한 항목을 순서대로 안내하게 했습니다. 설치 직후가 가장 쉽게 실패하는 구간이라, 그 구간에만 화면 하나를 통째로 썼습니다.

지금은 다릅니다. NAS 에 APK 를 올려 두는 대신 앱이 스스로 업데이트를 확인하고 받아 옵니다. 권한 화면도 하단 탭에서 설정 안쪽으로 옮겼습니다. 처음 설치할 때 한 번 쓰고 나면 다시 볼 일이 거의 없는 화면이 다섯 탭 가운데 하나를 차지할 이유가 없었습니다.
Since this was written: the app was renamed Fab Alert, and its structure changed considerably once RCS/MMS reception, message forwarding, and self-updating were added. The text below reflects that earlier point in time; where things have changed, each section says so. The sequel is Catching SMS Wasn't Enough.

What was needed is simple. When a text arrives containing a registered number or keyword, ring loudly during the chosen days and hours, ignoring silent mode and Do Not Disturb. What an emergency alert does, on conditions I define.

The feature is simple; the requirement is demanding. It has to ring without fail. Miss once and the app has no reason to exist.

Why the first structure that comes to mind is dangerous

The intuitive design is this — when the watch window begins, start a foreground service, and handle incoming messages for as long as that service is alive.

The problem is that in this structure, whether the alarm rings depends on "is the service up right now?" On Android that is not a guaranteed condition. OEM battery optimization, memory pressure, a schedule tick running a little late — any one of them and it silently does not ring. And you only find out afterwards.

So the decision moved

The revised principle: whether to ring is recomputed at the moment the message arrives, against the clock at that moment. It does not look at what else happens to be running.

The manifest-registered SmsReceiver is invoked when a message arrives even if the app is entirely shut down. Every time, it calls ScheduleUtil.isActiveNow() directly to check "is right now inside the configured watch window?" before going any further.

The existing foreground service was not removed. It was demoted. MonitorForegroundService now exists to show the user that monitoring is on, and re-evaluates the schedule on a one-minute AlarmManager tick to start and stop itself. If it runs late or is not running at all, actual alarm behavior is unaffected.

The part that actually makes it loud

When the conditions match, AlarmSoundService does three things at once.

  • Loops the sound on STREAM_ALARM — the stream that plays through silent and Do Not Disturb. It is the same mechanism the system alarm clock uses; not a trick, but what the stream is for.
  • Vibration
  • A fullScreenIntent notification that puts AlarmActivity over the lock screen

And that screen dismisses only via the "Stop" button. If a stray back gesture could dismiss it, there would be no difference between ringing and not ringing.

An empty configuration is not "allow everything"

One more point worth deliberating over, in MatchEngine. If the contact list and the keyword list are both empty, nothing matches.

  • Only one populated → evaluate that condition alone
  • Both populated → evaluate as AND
  • Both empty → no match

Making it "no conditions, so everything passes" means someone who has just opened the app for the first time gets an alarm for every text before they finish configuring it. The default has to be the quiet one.

This is different now. Contacts are no longer a matching condition but a forwarding target, and matching looks only at keywords. To also constrain the sender you attach a phone number to a keyword entry, and the AND applies to that entry alone. The rule that "an empty list matches nothing" still holds.

The internet permission was left out entirely

This app reads the sender and content of every text that reaches the device. So the INTERNET permission was simply never added to the manifest. "It does not transmit" becomes a fact enforced by the manifest rather than a promise made by the code. All storage happens in a local Room database and DataStore.

This is different now. Adding self-update left no choice but to include INTERNET. An app that does not go through a store has no path other than fetching its own updates. A guarantee that had been enforced by the manifest slid back down to being a promise made by the code, so that decision is written up separately in the next post.
This app must only be installed on a device you own, or a device whose owner has given explicit consent. Reading someone's text messages without their consent may violate communications privacy law. The same sentence appears at the top of the README.

Distribution is NAS sideloading

This is not the kind of app that goes on the Play Store, so the plan is a self-signed release APK placed on a NAS file share and installed from the phone.

./gradlew assembleRelease
# → app/build/outputs/apk/release/app-release.apk

Since it does not go through a store, every permission has to be granted manually after installation. So the app has a dedicated Permissions tab that walks through the required items in order. The moments right after installation are where this most often fails, so that stretch got a whole screen to itself.

This is different now. Instead of an APK on a NAS, the app checks for and fetches its own updates. The permissions screen also moved from the bottom tab bar to inside settings — there was no reason for a screen used once at first install, and almost never again, to occupy one of five tabs.