What happens when a bank SMS reaches Cipher
ยท by Sk Masum Ali
A bank text arrives. A moment later it is a row in an encrypted database, and nothing has left the phone. This is the path it takes in Cipher, because the interesting part is everything that can go wrong in between.
The receiver has very little time
Cipher listens for SMS_RECEIVED with a BroadcastReceiver. onReceive runs on the main thread, and once it returns the system is free to kill the process. Parsing a message and writing to a database inside it is asking for trouble. The fix is goAsync(), which tells the system the receiver isn't finished yet, so the work can happen on another thread.
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Telephony.Sms.Intents.SMS_RECEIVED_ACTION) {
val pendingResult = goAsync()
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
scope.launch {
try {
// parse each message, insert the transactions
} finally {
pendingResult.finish()
}
}
}
}Two details matter here. The scope is its own CoroutineScope(SupervisorJob() + Dispatchers.IO), because a receiver has no lifecycle to borrow one from. And finish() sits in a finally block, so a bug in the parser can never leave the result hanging.
Most texts are not transactions
Your phone gets OTPs, offers and delivery updates all day. The parser returns null for anything that isn't clearly a transaction, and the receiver only inserts when it gets something back. Rules are split by region (India, the UK, the US, the UAE, Singapore, Australia, Canada and the Euro area, plus a global fallback), and over 420 tests guard the formats. A wrong guess here is worse than a miss, because a made-up transaction quietly corrupts someone's budget.
The time on the transaction is when it happened
Texts can arrive late, for example when the phone was off. Stamping the transaction with the arrival time would put Friday's coffee on Monday. So Cipher uses the time the SMS was sent, and only falls back to the current time if that value is missing or in the future.
fun resolveTransactionTimestamp(sourceTimestampMillis: Long, nowMillis: Long): Long {
val isUsable = sourceTimestampMillis > 0L && sourceTimestampMillis <= nowMillis
return if (isUsable) sourceTimestampMillis else nowMillis
}If the key is gone, stop
Before inserting, the receiver asks whether the database key is still readable. If it isn't, it does nothing, because writing would mean guessing. Why that matters is the next post.