Receiving SMS
Inbound capture requires the full build flavor and the RECEIVE_SMS
permission. There is nothing else to configure — a paired node reports every
message that reaches the SIM.
The sendOnly flavor omits both the permission and the receiver registration.
Native exposes this as isReceiveSmsSupported() so the settings screen hides
a permission that could never be granted. See build
flavors.
The path
SMS_RECEIVED broadcast → SmsReceiver.goAsync()
→ hand PDUs to the service → MultipartAssembler.reassemble
→ InboxRepository.insert(RECEIVED) [PERSIST FIRST]
→ event:sms_received(id=E7) ─────────▶ Backend
→ Backend ack(E7) → inbox: mark reported/ackedThe ANR budget shapes the design
A BroadcastReceiver has roughly a ten-second budget before Android
declares an ANR. Reassembling, persisting and reporting inline would blow it
under load — and load is exactly when you least want the receiver to die.
So SmsReceiver does the minimum: it calls goAsync() to hold the broadcast
open, captures the PDUs, and hands them straight to the service. All real work
happens on a coroutine outside the receiver’s window.
This is the single most common way a homegrown SMS gateway loses messages silently. The receiver appears to work in testing — where messages arrive one at a time — and starts dropping them the moment a burst arrives.
Multipart reassembly
Concatenated SMS arrives as separate PDUs carrying a reference number, a part
count and an index. MultipartAssembler.reassemble joins them.
Parts can arrive out of order, and sometimes a part never arrives at all. The
assembler is therefore time-bounded: on REASSEMBLY_TIMEOUT the message is
reported as partial rather than held forever waiting for a part that is not
coming.
SmsReceiver.buildInbound is deliberately a pure function — PDUs in, domain
model out — so the parsing that is hardest to get right is unit-testable without
a device or a radio.
The event
{
"kind": "event",
"type": "sms_received",
"id": "evt_7",
"payload": {
"from": "+9779800000000",
"body": "…",
"subscriptionId": 1,
"receivedAt": "2026-07-19T09:00:00Z",
"parts": 1
}
}Handled server-side with:
luno.on('sms.received', async ({ deviceId, from, body, subscriptionId, receivedAt, parts }) => {
await db.messages.insert({ deviceId, from, body, receivedAt })
})Delivery guarantees
Inbound reporting is at-least-once. The event is persisted in the node’s durable event outbox under a stable id, resent on every reconnect until the backend acks it, and cleared on ack. It survives process death, not just socket drops.
Your sms.received handler must be idempotent. A network drop between the
backend persisting the message and the node receiving the ack results in a
redelivery of the same event id. Dedupe on the event id, or use it as a
primary key.
The ack follow-up is itself keyed off a persisted correlationId, so the chain
survives a restart at any point in it.
What is not captured
- MMS is out of scope for v1. An MMS arriving at the SIM is not reported.
- RCS is not accessible to third-party apps at all.
- Messages received while the app was force-stopped are not delivered by Android to a stopped app. This is one of the reasons the node’s health state matters — a silently stopped node is not just idle, it is missing inbound traffic that will never be recoverable.
- Class 0 / flash messages and carrier service messages are delivered by the system differently and may not reach the receiver.