앱이 꺼져 있어도 반드시 울리게 만들기
필요한 기능은 단순했습니다. 정해 둔 번호나 키워드가 담긴 문자가 오면, 정해 둔 요일과 시간대에는 무음이든 방해 금지 모드든 무시하고 크게 울리는 것입니다. 재난 문자가 하는 동작을 제가 정한 조건으로 하게 만드는 셈입니다.
기능은 단순해도 요구 조건은 까다롭습니다. 반드시 울려야 하기 때문입니다. 한 번이라도 울리지 않으면 이 앱은 있을 이유가 없어집니다.
처음 떠올린 구조가 위험한 이유
직관적인 설계는 이렇습니다. 감시 시간대가 되면 포그라운드 서비스를 켜고, 그 서비스가 살아 있는 동안 들어오는 문자를 처리합니다.
이 구조에서는 알람이 울릴지가 서비스가 지금 떠 있는지에 달려 있습니다. 안드로이드에서 그것은 보장되는 조건이 아닙니다. 제조사의 배터리 최적화나 메모리 부족, 조금 늦게 도는 스케줄 타이머가 언제든 끼어들 수 있습니다. 어느 하나에만 걸려도 알람은 조용히 울리지 않고, 울리지 않았다는 사실은 나중에야 알게 됩니다.
판단 시점을 옮기다
매니페스트에 등록한 SmsReceiver 는 앱이 완전히 꺼져 있어도 문자가 오면 호출됩니다. 여기서 매번 ScheduleUtil.isActiveNow() 를 직접 불러 "지금이 설정한 감시 시간대인가"를 확인한 뒤에만 다음 단계로 넘어갑니다.
기존의 포그라운드 서비스는 없애지 않고 역할을 낮췄습니다. 이제 MonitorForegroundService 는 "감시 중"이라는 상태를 사용자에게 보여 주는 표시용이고, 1분 간격의 AlarmManager 타이머로 스케줄을 다시 따져 스스로 켜지고 꺼집니다. 이 서비스가 늦거나 꺼져 있어도 실제 알람 동작에는 영향이 없습니다.
실제로 크게 울리게 하는 부분
조건이 맞으면 AlarmSoundService 가 세 가지를 동시에 합니다.
STREAM_ALARM으로 소리를 반복 재생합니다. 무음이나 방해 금지 모드에서도 재생되는 스트림이고, 시스템 알람 앱이 쓰는 것과 같은 방식이라 편법이 아닙니다.- 진동을 울립니다.
fullScreenIntent알림으로AlarmActivity를 잠금 화면 위에 띄웁니다.
이 화면은 중지 버튼으로만 꺼집니다. 뒤로 가기로 실수로 닫힐 수 있다면 울린 것과 울리지 않은 것의 차이가 없어지기 때문입니다.
빈 설정은 "전체 허용"이 아니다
MatchEngine 에서 한 번 더 고민한 부분입니다. 연락처 목록과 키워드 목록이 둘 다 비어 있으면 어떤 문자도 걸지 않습니다.
- 한쪽만 등록 → 그 조건만 봅니다
- 둘 다 등록 → 두 조건을 모두 만족해야 합니다(AND)
- 둘 다 비어 있음 → 아무것도 걸지 않습니다
조건이 없으니 모두 통과시키도록 만들면, 앱을 처음 켠 사람이 설정을 마치기도 전에 모든 문자에 알람이 울립니다. 기본값은 조용한 쪽이어야 합니다.
인터넷 권한을 아예 넣지 않았다
이 앱은 기기에 도착하는 모든 문자의 발신 번호와 내용을 읽기 때문에, 매니페스트에 INTERNET 권한을 아예 넣지 않았습니다. 외부로 보내지 않는다는 것이 코드의 약속에 그치지 않고 매니페스트가 강제하는 사실이 됩니다. 저장은 전부 기기 안의 Room DB 와 DataStore 에서만 일어납니다.
INTERNET 권한을 넣을 수밖에
없었습니다. 스토어를 거치지 않는 앱은 스스로 업데이트를 받아 오는 것 말고는 방법이 없기
때문입니다. 매니페스트가 강제하던 보장이 다시 코드의 약속으로 내려온 셈이어서, 이 결정은
다음 글에서 따로 정리했습니다.
배포는 NAS 를 통한 직접 설치로
플레이스토어에 올릴 성격의 앱이 아니어서, 직접 서명한 릴리스 APK 를 만들어 NAS 파일 공유에 올려 두고 휴대폰에서 받아 설치하는 방식을 택했습니다.
./gradlew assembleRelease
# → app/build/outputs/apk/release/app-release.apk
스토어를 거치지 않으니 설치한 뒤 권한을 전부 손으로 켜야 합니다. 앱 안에 권한 탭을 따로 만들어 필요한 항목을 순서대로 안내하게 했습니다. 설치 직후가 가장 쉽게 실패하는 구간이라, 그 구간에만 화면 하나를 통째로 썼습니다.
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 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
fullScreenIntentnotification that putsAlarmActivityover 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.
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.
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.
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.