v1.5.0: multi-SIM detection + self-healing data accuracy

SIM detection: history import now attributes per-message sub_id (sim_id
fallback), receiver falls back to the sole active SIM when the OEM omits
the subscription extra, accounts re-link by phone number on SIM/slot
changes, and READ_PHONE_STATE is requested so lines get carrier names.

Accuracy: KCB withdrawals stored the savings-pocket balance as the wallet
balance and INFLOWs as OUTFLOW (goal contributions counted withdrawals).
Wallet-balance regex now wins and 'from your KCB/M-Shwari' parses as
inflow. Combined view sums per-line balances and Fuliza facts instead of
mixing lines; insights run account-agnostic for deterministic output.

Self-healing on launch: SIM sync, incremental catch-up SMS import (covers
receiver kills by OEM battery savers), one-time unassigned-only SIM
backfill, and a versioned re-parse that heals stored rows after parser
fixes while preserving user-set fields.
This commit is contained in:
ngenohkevin
2026-06-10 09:07:10 +03:00
parent d7ab85f97d
commit ec26507476
15 changed files with 546 additions and 124 deletions
+2 -2
View File
@@ -27,8 +27,8 @@ android {
applicationId = "com.ledga.app"
minSdk = 26
targetSdk = 34
versionCode = 17
versionName = "1.4.7"
versionCode = 18
versionName = "1.5.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -1,5 +1,8 @@
package com.ledga.app
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -21,8 +24,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.core.content.ContextCompat
import com.ledga.app.data.repository.AccountsRepository
import com.ledga.app.data.repository.FontScale
import com.ledga.app.data.repository.HistoricalBackfillRepository
import com.ledga.app.data.repository.SettingsRepository
import com.ledga.app.data.repository.SmsImporter
import com.ledga.app.data.repository.TransactionRepository
import com.ledga.app.ui.navigation.AppNavigation
import com.ledga.app.ui.navigation.HomeRoute
import com.ledga.app.ui.navigation.LedgaBottomNavBar
@@ -31,14 +39,23 @@ import com.ledga.app.ui.theme.LedgaTheme
import com.ledga.app.ui.theme.ThemeMode
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
settingsRepository: SettingsRepository
settingsRepository: SettingsRepository,
accountsRepository: AccountsRepository,
backfillRepository: HistoricalBackfillRepository,
transactionRepository: TransactionRepository,
smsImporter: SmsImporter,
@ApplicationContext context: Context,
) : ViewModel() {
val themeMode: StateFlow<ThemeMode> = settingsRepository.getThemeMode()
@@ -49,6 +66,45 @@ class MainViewModel @Inject constructor(
val fontScale: StateFlow<FontScale> = settingsRepository.getFontScale()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), FontScale.SYSTEM)
init {
viewModelScope.launch(Dispatchers.IO) {
// Keep account↔SIM links fresh: slot moves and eSIM re-provisions
// change the subscription id, and phone numbers become readable
// once READ_PHONE_STATE is granted.
runCatching { accountsRepository.syncActiveSubscriptions() }
// One-time silent attribution of history imported before the
// importer recorded each SMS's SIM. Only touches rows that are
// still unassigned, so user attributions always survive.
val hasReadSms = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_SMS
) == PackageManager.PERMISSION_GRANTED
val onboarded = settingsRepository.hasCompletedOnboarding().first()
// Catch-up sync: pick up any M-Pesa SMS the receiver missed
// (battery killers, force-stop) since the last run. Incremental
// and duplicate-safe, so it runs on every start.
if (hasReadSms && onboarded) {
runCatching { smsImporter.catchUp() }
}
if (hasReadSms && onboarded && !settingsRepository.isSimBackfillDone().first()) {
runCatching { backfillRepository.runAutoBackfill(onlyUnassigned = true) }
.onSuccess { settingsRepository.setSimBackfillDone() }
}
// Retroactive parser fixups: when a parser bug fix lands (e.g. KCB
// withdrawals storing the savings-pocket balance as the wallet
// balance), stored rows are re-derived from their rawSms once.
// User-set fields (category, account, note) are preserved.
val fixup = settingsRepository.currentParserFixup
if (onboarded && settingsRepository.getParserFixupVersion().first() < fixup) {
runCatching { transactionRepository.reparseAllTransactions() }
.onSuccess { settingsRepository.setParserFixupVersion(fixup) }
}
}
}
}
@AndroidEntryPoint
@@ -77,6 +77,16 @@ interface TransactionDao {
""")
suspend fun updateAccountForCodes(accountId: Long, codes: List<String>): Int
/**
* Like [updateAccountForCodes] but never touches rows the user (or a
* previous backfill) already attributed — safe to run unattended.
*/
@Query("""
UPDATE transactions SET accountId = :accountId
WHERE transactionCode IN (:codes) AND accountId IS NULL
""")
suspend fun updateAccountForCodesIfUnassigned(accountId: Long, codes: List<String>): Int
@Transaction
@Query("""
SELECT * FROM transactions
@@ -118,6 +128,30 @@ interface TransactionDao {
""")
fun getLatestTransactionWithBalance(accountId: Long? = null): Flow<TransactionEntity?>
/**
* Combined-view balance: each line's wallet is independent, so the
* headline figure must be the SUM of every account's latest balance —
* NOT the balance on the most recent SMS overall (that's one line only,
* which understates a dual-SIM user's real position). Rows still
* unattributed to a line are excluded; the balance-validity exclusions
* mirror [getLatestTransactionWithBalance]. NULL when no account has a
* balance-carrying transaction yet.
*/
@Query("""
SELECT SUM(balance) FROM transactions
WHERE id IN (
SELECT (
SELECT t.id FROM transactions t
WHERE t.accountId = a.id
AND NOT (t.balance = 0 AND t.type IN
('FULIZA_REPAYMENT','FULIZA_REVERSAL','REVERSAL','FULIZA','UNKNOWN'))
ORDER BY t.timestamp DESC, t.id DESC LIMIT 1
)
FROM mpesa_accounts a
)
""")
fun getCombinedLatestBalance(): Flow<Double?>
/*
* Spending queries share three exclusions beyond direction = OUTFLOW:
* - reversed originals (the money came back)
@@ -379,20 +413,25 @@ interface TransactionDao {
suspend fun deleteById(id: Long)
// ---- Fuliza status (latest known values from SMS) ----
// Fuliza is a per-LINE facility: each SIM has its own limit and
// outstanding. These queries take the account filter so dual-SIM users
// never see line A's limit mixed with line B's outstanding.
@Query("""
SELECT * FROM transactions
WHERE fulizaLimit IS NOT NULL
AND (:accountId IS NULL OR accountId = :accountId)
ORDER BY timestamp DESC LIMIT 1
""")
fun getLatestFulizaLimit(): Flow<TransactionEntity?>
fun getLatestFulizaLimit(accountId: Long? = null): Flow<TransactionEntity?>
@Query("""
SELECT * FROM transactions
WHERE fulizaOutstanding IS NOT NULL
AND (:accountId IS NULL OR accountId = :accountId)
ORDER BY timestamp DESC LIMIT 1
""")
fun getLatestFulizaOutstanding(): Flow<TransactionEntity?>
fun getLatestFulizaOutstanding(accountId: Long? = null): Flow<TransactionEntity?>
// ---- Own-account (transfer) recipient marking ----
@@ -30,8 +30,11 @@ class InsightEngine @Inject constructor(
suspend fun run(now: Long = System.currentTimeMillis()): Int {
val ninetyDaysAgo = now - 90L * DAY_MS
// Deliberately unfiltered by account: the worker runs in the
// background, and insights must be deterministic — not a function of
// whichever SIM the user had selected when the worker fired.
val txns = transactionRepository
.getTransactions(ninetyDaysAgo, now)
.getTransactionsAllAccounts(ninetyDaysAgo, now)
.first()
.map { it.transaction }
.sortedByDescending { it.timestamp }
@@ -48,6 +48,16 @@ object MpesaSmsParser {
// Balance: multiple formats — note "Ksh" is optional. Fuliza auto-pay SMS
// reads "Your M-PESA balance is 8581.08" with no Ksh prefix on some
// ROMs / carrier formats, and we still need to capture it.
//
// Savings SMS carry TWO balances ("KCB M-PESA Account balance is Ksh0.17.
// New M-PESA balance is Ksh15,000.00.") and the wallet one isn't always
// first — so we try the wallet-specific pattern before the generic one.
// "M-PESA balance is" can't accidentally match the savings figure: those
// read "…M-PESA Account/Saving account balance is", with words between.
private val WALLET_BALANCE_REGEX = Regex(
"""(?:M-PESA balance is|MPESA balance is)\s*(?:Ksh)?\s?([\d,]+\.\d{2})""",
RegexOption.IGNORE_CASE,
)
private val BALANCE_REGEX = Regex(
"""(?:M-PESA balance is|MPESA balance is|account balance is)\s*(?:Ksh)?\s?([\d,]+\.\d{2})""",
RegexOption.IGNORE_CASE,
@@ -504,7 +514,9 @@ object MpesaSmsParser {
private fun parseMshwari(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
val isInflow = sms.contains("from M-Shwari", ignoreCase = true)
// "transferred from M-Shwari" / "from your M-Shwari account" — money
// coming back INTO the wallet.
val isInflow = sms.contains(Regex("""from (?:your )?M-Shwari""", RegexOption.IGNORE_CASE))
return ParsedTransaction(
transactionCode = code, type = TransactionType.MSHWARI, amount = amount,
transactionCost = cost, recipientName = "M-Shwari", recipientPhone = null,
@@ -517,7 +529,10 @@ object MpesaSmsParser {
private fun parseKcbMpesa(sms: String, code: String, balance: Double, cost: Double, timestamp: Long): ParsedTransaction {
val amount = extractFirstAmount(sms) ?: 0.0
val isInflow = sms.contains("from KCB", ignoreCase = true)
// The withdrawal SMS reads "transfered Ksh… from YOUR KCB M-PESA
// account" — a bare "from KCB" check misses it, flagging the money
// you pulled back into the wallet as an outflow.
val isInflow = sms.contains(Regex("""from (?:your )?KCB""", RegexOption.IGNORE_CASE))
return ParsedTransaction(
transactionCode = code, type = TransactionType.KCB_MPESA, amount = amount,
transactionCost = cost, recipientName = "KCB M-Pesa", recipientPhone = null,
@@ -550,6 +565,7 @@ object MpesaSmsParser {
}
private fun extractBalance(sms: String): Double? {
WALLET_BALANCE_REGEX.find(sms)?.groupValues?.get(1)?.let { return parseAmount(it) }
return BALANCE_REGEX.find(sms)?.groupValues?.get(1)?.let { parseAmount(it) }
}
@@ -3,6 +3,7 @@ package com.ledga.app.data.repository
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.telephony.SubscriptionInfo
import android.telephony.SubscriptionManager
import androidx.core.content.ContextCompat
import com.ledga.app.data.db.dao.MpesaAccountDao
@@ -46,6 +47,19 @@ class AccountsRepository @Inject constructor(
accountDao.findBySubscriptionId(subscriptionId)?.let { return it }
val (displayName, phone) = lookupSubscriptionInfo(subscriptionId)
// A SIM that moves slots or gets re-provisioned (eSIM) is assigned a
// NEW subscription id, but the line itself — phone number, history —
// is the same. Re-link the existing account instead of spawning a
// duplicate "Line 2" that splits the user's analytics in half.
if (phone != null) {
accountDao.getAll().firstOrNull { it.phoneNumber == phone }?.let { existing ->
val relinked = existing.copy(subscriptionId = subscriptionId)
accountDao.update(relinked)
return relinked
}
}
val existingCount = accountDao.getAll().size
val nextNumber = existingCount + 1
@@ -81,6 +95,76 @@ class AccountsRepository @Inject constructor(
accountDao.deleteById(id)
}
/**
* Make an SMS's subscription id usable for attribution. Some OEMs omit
* the subscription extra on the SMS broadcast (and the sub_id column in
* the SMS provider). When that happens and exactly ONE SIM is active,
* the message can only have come from that SIM — attribute it there.
* With two active SIMs we never guess; the transaction stays unassigned
* rather than being silently mis-attributed.
*/
fun resolveSubscriptionId(rawSubscriptionId: Int): Int {
if (rawSubscriptionId != SubscriptionManager.INVALID_SUBSCRIPTION_ID) return rawSubscriptionId
return activeSubscriptions().singleOrNull()?.subscriptionId
?: SubscriptionManager.INVALID_SUBSCRIPTION_ID
}
/**
* Reconcile stored accounts with the SIMs currently in the device.
* Run on app start so detection stays accurate as hardware changes:
* - re-link accounts whose SIM got a new subscription id (slot moved,
* SIM swapped out and back, eSIM re-provisioned) by phone number
* - backfill phone numbers that weren't readable at account creation
* Never renames, recolors, or deletes — user-set fields are sacred and
* accounts for removed SIMs keep their history.
*/
suspend fun syncActiveSubscriptions() {
val active = activeSubscriptions()
if (active.isEmpty()) return
val accounts = accountDao.getAll()
for (info in active) {
val phone = readNumber(info)
val byId = accounts.firstOrNull { it.subscriptionId == info.subscriptionId }
if (byId != null) {
if (byId.phoneNumber == null && phone != null) {
accountDao.update(byId.copy(phoneNumber = phone))
}
continue
}
// No account under this sub id — same line known under an old one?
if (phone != null) {
accounts.firstOrNull { it.phoneNumber == phone }?.let {
accountDao.update(it.copy(subscriptionId = info.subscriptionId))
}
}
}
}
private fun activeSubscriptions(): List<SubscriptionInfo> {
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_PHONE_STATE
) != PackageManager.PERMISSION_GRANTED
) {
return emptyList()
}
return try {
val mgr = context.getSystemService(SubscriptionManager::class.java)
?: return emptyList()
@Suppress("MissingPermission")
mgr.activeSubscriptionInfoList.orEmpty()
} catch (_: SecurityException) {
emptyList()
}
}
private fun readNumber(info: SubscriptionInfo): String? = try {
@Suppress("MissingPermission", "DEPRECATION")
info.number?.takeIf { it.isNotBlank() }
} catch (_: SecurityException) {
null
}
/**
* Resolve display name + phone number for a subscription ID.
* Returns nulls when READ_PHONE_STATE is denied (graceful degradation).
@@ -1,8 +1,5 @@
package com.ledga.app.data.repository
import android.content.ContentResolver
import android.database.Cursor
import android.net.Uri
import android.telephony.SubscriptionManager
import com.ledga.app.data.db.dao.TransactionDao
import kotlinx.coroutines.Dispatchers
@@ -39,7 +36,7 @@ data class DateRangeBackfillResult(val updated: Int)
*/
@Singleton
class HistoricalBackfillRepository @Inject constructor(
private val contentResolver: ContentResolver,
private val inbox: MpesaInbox,
private val transactionDao: TransactionDao,
private val accountsRepository: AccountsRepository,
) {
@@ -47,10 +44,18 @@ class HistoricalBackfillRepository @Inject constructor(
/** Code regex matches the parser — 10 alphanumeric at the message start. */
private val codeRegex = Regex("""^([A-Z0-9]{10})\s""")
/**
* @param onlyUnassigned when true, only rows whose accountId is still
* NULL are updated — manual per-transaction and date-range attributions
* survive. Used by the silent on-upgrade backfill; the user-initiated
* button keeps the overwrite-everything behavior (it's their explicit
* call to re-derive attribution from the SMS DB).
*/
suspend fun runAutoBackfill(
onlyUnassigned: Boolean = false,
onProgress: (scanned: Int, total: Int) -> Unit = { _, _ -> },
): BackfillResult = withContext(Dispatchers.IO) {
val rows = readMpesaInbox()
val rows = inbox.read()
val total = rows.size
if (total == 0) return@withContext BackfillResult(0, 0, 0, 0)
@@ -79,7 +84,11 @@ class HistoricalBackfillRepository @Inject constructor(
val account = accountsRepository.getOrCreateForSubscription(subId) ?: continue
// Update in chunks to avoid SQLite's IN-list limit (~1000 typically).
codes.chunked(500).forEach { chunk ->
taggedTotal += transactionDao.updateAccountForCodes(account.id, chunk)
taggedTotal += if (onlyUnassigned) {
transactionDao.updateAccountForCodesIfUnassigned(account.id, chunk)
} else {
transactionDao.updateAccountForCodes(account.id, chunk)
}
}
}
@@ -102,59 +111,4 @@ class HistoricalBackfillRepository @Inject constructor(
)
}
/**
* Read MPESA SMS rows + their subscription column. The column name varies:
* - "sub_id" on AOSP and most OEMs (since ~API 22)
* - Some Samsung / Xiaomi builds use "sim_id" or omit it entirely
* We probe both and fall back to a cursor without the column.
*/
private fun readMpesaInbox(): List<InboxRow> {
val uri = Uri.parse("content://sms/inbox")
// Try sub_id first (the AOSP name).
readWithColumns(uri, arrayOf("body", "date", "sub_id"))?.let { return it }
// Fallback: sim_id (Samsung ROMs).
readWithColumns(uri, arrayOf("body", "date", "sim_id"))?.let { return it }
// No SIM column — still useful; sub_ids will all come back invalid.
return readWithColumns(uri, arrayOf("body", "date")).orEmpty()
}
private fun readWithColumns(uri: Uri, projection: Array<String>): List<InboxRow>? = try {
val cursor: Cursor? = contentResolver.query(
uri,
projection,
// FULIZA: borrow confirmations can arrive from a dedicated sender id.
"address IN (?, ?)",
arrayOf("MPESA", "FULIZA"),
"date DESC",
)
cursor?.use { c ->
val bodyIdx = c.getColumnIndex("body")
val dateIdx = c.getColumnIndex("date")
val subIdx = projection.lastIndex
.takeIf { projection.size > 2 }
?.let { c.getColumnIndex(projection[it]) }
?: -1
val rows = mutableListOf<InboxRow>()
while (c.moveToNext()) {
val body = if (bodyIdx >= 0) c.getString(bodyIdx) else continue
val date = if (dateIdx >= 0) c.getLong(dateIdx) else 0L
val sub = if (subIdx >= 0) c.getInt(subIdx)
else SubscriptionManager.INVALID_SUBSCRIPTION_ID
rows += InboxRow(body = body, date = date, subscriptionId = sub)
}
rows
}
} catch (_: IllegalArgumentException) {
// Projection contained an unknown column — try the next fallback.
null
} catch (_: SecurityException) {
// READ_SMS not granted — caller should have checked, but be defensive.
null
}
private data class InboxRow(
val body: String,
val date: Long,
val subscriptionId: Int,
)
}
@@ -0,0 +1,74 @@
package com.ledga.app.data.repository
import android.content.ContentResolver
import android.database.Cursor
import android.net.Uri
import android.telephony.SubscriptionManager
import javax.inject.Inject
import javax.inject.Singleton
/**
* Shared reader for M-Pesa rows in Android's SMS inbox, including each
* message's SIM subscription id when the OEM preserves it.
*
* The subscription column name varies:
* - "sub_id" on AOSP and most OEMs (since ~API 22)
* - some Samsung / Xiaomi builds use "sim_id" or omit it entirely
* We probe both and fall back to a cursor without the column, in which case
* every row reports [SubscriptionManager.INVALID_SUBSCRIPTION_ID].
*/
@Singleton
class MpesaInbox @Inject constructor(
private val contentResolver: ContentResolver,
) {
data class Row(
val body: String,
val date: Long,
val subscriptionId: Int,
)
/** @param since only rows with a receive date strictly after this (0 = everything). */
fun read(since: Long = 0L): List<Row> {
val uri = Uri.parse("content://sms/inbox")
readWithColumns(uri, arrayOf("body", "date", "sub_id"), since)?.let { return it }
readWithColumns(uri, arrayOf("body", "date", "sim_id"), since)?.let { return it }
return readWithColumns(uri, arrayOf("body", "date"), since).orEmpty()
}
private fun readWithColumns(uri: Uri, projection: Array<String>, since: Long): List<Row>? = try {
// FULIZA: borrow confirmations can arrive from a dedicated sender id.
val selection = if (since > 0) "address IN (?, ?) AND date > ?" else "address IN (?, ?)"
val args = if (since > 0) arrayOf("MPESA", "FULIZA", since.toString())
else arrayOf("MPESA", "FULIZA")
val cursor: Cursor? = contentResolver.query(
uri,
projection,
selection,
args,
"date DESC",
)
cursor?.use { c ->
val bodyIdx = c.getColumnIndex("body")
val dateIdx = c.getColumnIndex("date")
val subIdx = projection.lastIndex
.takeIf { projection.size > 2 }
?.let { c.getColumnIndex(projection[it]) }
?: -1
val rows = mutableListOf<Row>()
while (c.moveToNext()) {
val body = if (bodyIdx >= 0) c.getString(bodyIdx) else continue
val date = if (dateIdx >= 0) c.getLong(dateIdx) else 0L
val sub = if (subIdx >= 0 && !c.isNull(subIdx)) c.getInt(subIdx)
else SubscriptionManager.INVALID_SUBSCRIPTION_ID
rows += Row(body = body, date = date, subscriptionId = sub)
}
rows
}
} catch (_: IllegalArgumentException) {
// Projection contained an unknown column — try the next fallback.
null
} catch (_: SecurityException) {
// READ_SMS not granted — caller should have checked, but be defensive.
null
}
}
@@ -34,6 +34,15 @@ class SettingsRepository @Inject constructor(
// Self-update: which version the user "Remind me later"'d. Banner stays hidden
// until a newer version is available.
private val DISMISSED_UPDATE_VERSION = stringPreferencesKey("dismissed_update_version")
// One-time silent SIM-attribution backfill for installs that imported
// history before the importer recorded sub_id (pre-v1.5).
private val SIM_BACKFILL_DONE = booleanPreferencesKey("sim_backfill_done")
// Bump CURRENT_PARSER_FIXUP whenever a parser fix should be applied
// retroactively to stored rows; rows are re-parsed from rawSms once.
private val PARSER_FIXUP_VERSION = intPreferencesKey("parser_fixup_version")
// Newest SMS-inbox `date` already imported. The startup catch-up sync
// only scans rows after this. 0 = no import has run yet.
private val SMS_SYNC_WATERMARK = longPreferencesKey("sms_sync_watermark")
}
fun getThemeMode(): Flow<ThemeMode> = dataStore.data.map { prefs ->
@@ -109,6 +118,24 @@ class SettingsRepository @Inject constructor(
}
}
// --- One-time SIM-attribution backfill ---
fun isSimBackfillDone(): Flow<Boolean> = dataStore.data.map { it[SIM_BACKFILL_DONE] ?: false }
suspend fun setSimBackfillDone() { dataStore.edit { it[SIM_BACKFILL_DONE] = true } }
// --- SMS catch-up sync watermark ---
fun getSmsSyncWatermark(): Flow<Long> = dataStore.data.map { it[SMS_SYNC_WATERMARK] ?: 0L }
suspend fun setSmsSyncWatermark(value: Long) { dataStore.edit { it[SMS_SYNC_WATERMARK] = value } }
// --- Retroactive parser fixups ---
/** v1: KCB/M-Shwari wallet-balance + direction fix (June 2026). */
val currentParserFixup = 1
fun getParserFixupVersion(): Flow<Int> = dataStore.data.map { it[PARSER_FIXUP_VERSION] ?: 0 }
suspend fun setParserFixupVersion(version: Int) { dataStore.edit { it[PARSER_FIXUP_VERSION] = version } }
// --- Self-update: snoozed version ---
fun getDismissedUpdateVersion(): Flow<String?> = dataStore.data.map { prefs ->
@@ -1,10 +1,9 @@
package com.ledga.app.data.repository
import android.content.ContentResolver
import android.net.Uri
import com.ledga.app.data.parser.MpesaSmsParser
import com.ledga.app.data.parser.ParseResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@@ -12,27 +11,70 @@ import javax.inject.Singleton
data class ImportResult(
val total: Int,
val imported: Int,
val failed: Int
val failed: Int,
/** Distinct SIM lines the import attributed transactions to. */
val accountsDetected: Int = 0,
)
@Singleton
class SmsImporter @Inject constructor(
private val contentResolver: ContentResolver,
private val transactionRepository: TransactionRepository
private val inbox: MpesaInbox,
private val transactionRepository: TransactionRepository,
private val accountsRepository: AccountsRepository,
private val settingsRepository: SettingsRepository,
) {
/** Full-history import: onboarding and the Settings re-import button. */
suspend fun importHistory(
onProgress: (imported: Int, total: Int) -> Unit = { _, _ -> }
): ImportResult = withContext(Dispatchers.IO) {
val smsList = readMpesaSms()
importRows(inbox.read(), onProgress)
}
/**
* Catch-up sync, run on every app start. The SMS receiver can miss
* messages — OEM battery killers, force-stopped app, an update mid-SMS —
* and without this the gap silently persists until the user finds the
* manual re-import button. Incremental: only inbox rows newer than the
* watermark (minus an overlap window) are scanned, and duplicate codes
* are dropped by insertTransaction, so running it often is cheap.
*
* First run (watermark unset) does a full import — for installs that
* predate the watermark this also fills any historical gaps in one go.
*/
suspend fun catchUp(): ImportResult = withContext(Dispatchers.IO) {
val watermark = settingsRepository.getSmsSyncWatermark().first()
if (watermark == 0L) return@withContext importHistory()
importRows(inbox.read(since = watermark - OVERLAP_MS))
}
private suspend fun importRows(
smsList: List<MpesaInbox.Row>,
onProgress: (imported: Int, total: Int) -> Unit = { _, _ -> },
): ImportResult {
val total = smsList.size
var imported = 0
var failed = 0
// Attribute each SMS to its SIM at import time — the inbox keeps a
// sub_id per message, so dual-SIM users get per-line history from
// day one instead of needing a manual backfill afterwards.
val accountIdBySub = mutableMapOf<Int, Long?>()
suspend fun accountIdFor(rawSubscriptionId: Int): Long? {
val sub = accountsRepository.resolveSubscriptionId(rawSubscriptionId)
if (!accountIdBySub.containsKey(sub)) {
accountIdBySub[sub] = accountsRepository.getOrCreateForSubscription(sub)?.id
}
return accountIdBySub[sub]
}
smsList.forEachIndexed { index, sms ->
val result = MpesaSmsParser.parse(sms.body, sms.date)
when (result) {
is ParseResult.Success -> {
val id = transactionRepository.insertTransaction(result.transaction)
val id = transactionRepository.insertTransaction(
parsed = result.transaction,
accountId = accountIdFor(sms.subscriptionId),
)
if (id != -1L) imported++
}
is ParseResult.Failure -> failed++
@@ -40,36 +82,28 @@ class SmsImporter @Inject constructor(
onProgress(index + 1, total)
}
ImportResult(total = total, imported = imported, failed = failed)
}
private fun readMpesaSms(): List<SmsMessage> {
val messages = mutableListOf<SmsMessage>()
val uri = Uri.parse("content://sms/inbox")
val cursor = contentResolver.query(
uri,
arrayOf("body", "date", "address"),
// FULIZA: borrow confirmations can arrive from a dedicated sender id.
"address IN (?, ?)",
arrayOf("MPESA", "FULIZA"),
"date DESC"
// Advance the watermark to the newest inbox row we processed so the
// next catch-up only scans what arrived after this run.
val previous = settingsRepository.getSmsSyncWatermark().first()
val newest = smsList.maxOfOrNull { it.date } ?: 0L
settingsRepository.setSmsSyncWatermark(
maxOf(previous, newest, if (previous == 0L) System.currentTimeMillis() else 0L)
)
cursor?.use {
val bodyIndex = it.getColumnIndexOrThrow("body")
val dateIndex = it.getColumnIndexOrThrow("date")
while (it.moveToNext()) {
messages.add(
SmsMessage(
body = it.getString(bodyIndex),
date = it.getLong(dateIndex)
)
)
}
}
return messages
return ImportResult(
total = total,
imported = imported,
failed = failed,
accountsDetected = accountIdBySub.values.filterNotNull().distinct().size,
)
}
private data class SmsMessage(val body: String, val date: Long)
companion object {
/**
* Re-scan window behind the watermark. SMS `date` is receive time
* and can land slightly out of order across SIMs/reboots; the code
* dedupe makes overlap free, missing a message is not.
*/
private const val OVERLAP_MS = 6 * 60 * 60 * 1000L
}
}
@@ -265,9 +265,34 @@ class TransactionRepository @Inject constructor(
// ---- Own-account (transfer) recipients ----
/** Latest known available Fuliza limit / outstanding, from any SMS that carried them. */
fun getLatestFulizaLimit(): Flow<TransactionEntity?> = transactionDao.getLatestFulizaLimit()
fun getLatestFulizaOutstanding(): Flow<TransactionEntity?> = transactionDao.getLatestFulizaOutstanding()
/**
* Latest known Fuliza limit / outstanding for the SELECTED line. Fuliza
* is per-SIM, so these follow the account switcher like every other
* screen-facing query.
*/
fun getLatestFulizaLimit(): Flow<TransactionEntity?> =
selectedAccount.flatMapLatest { transactionDao.getLatestFulizaLimit(it) }
fun getLatestFulizaOutstanding(): Flow<TransactionEntity?> =
selectedAccount.flatMapLatest { transactionDao.getLatestFulizaOutstanding(it) }
/** Explicit-account variants — used to build the per-line sums on the Combined view. */
fun getLatestFulizaLimitFor(accountId: Long?): Flow<TransactionEntity?> =
transactionDao.getLatestFulizaLimit(accountId)
fun getLatestFulizaOutstandingFor(accountId: Long?): Flow<TransactionEntity?> =
transactionDao.getLatestFulizaOutstanding(accountId)
/** Sum of every line's latest balance — the Combined view's headline figure. */
fun getCombinedLatestBalance(): Flow<Double?> = transactionDao.getCombinedLatestBalance()
/**
* Account-agnostic window for the insights engine. The engine runs from
* a background worker; its output must not depend on which line the user
* happened to have selected when the worker fired.
*/
fun getTransactionsAllAccounts(startTime: Long, endTime: Long): Flow<List<TransactionWithCategory>> =
transactionDao.getTransactions(startTime, endTime, null)
/** Recipient fragments the user marked as their own accounts. */
fun getOwnAccountFragments(): Flow<List<String>> = categoryRuleDao.getOwnAccountFragments()
@@ -46,7 +46,10 @@ class SmsReceiver : BroadcastReceiver() {
// same SMS — keep the first valid one we see per sender.
val partsBySender = messages.groupBy { it.displayOriginatingAddress }
val subscriptionId = subscriptionIdFromIntent(intent)
// Some OEMs ship the broadcast without the subscription extra;
// resolveSubscriptionId falls back to the sole active SIM.
val subscriptionId =
accountsRepository.resolveSubscriptionId(subscriptionIdFromIntent(intent))
for ((sender, parts) in partsBySender) {
if (!MpesaSmsParser.isMpesaMessage(sender ?: "")) continue
@@ -25,6 +25,7 @@ import com.ledga.app.worker.UpdateChecker
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -109,6 +111,81 @@ class HomeViewModel @Inject constructor(
private val _selectedPeriod = MutableStateFlow(Period.TODAY)
/** Latest Fuliza figures for one line, plus the cross-line merge result. */
private data class FulizaFacts(
val outstanding: Double? = null,
val outstandingAt: Long? = null,
val ceiling: Double? = null,
val available: Double? = null,
)
private val accountContext = combine(
settingsRepository.getSelectedAccountId(),
accountsRepository.observeAll(),
) { selected, accounts -> selected to accounts }
/**
* Balance for the Home card. Each SIM's wallet is independent, so the
* Combined view must show the SUM of every line's latest balance — the
* balance on the most recent SMS alone is just one line's wallet and
* understates a dual-SIM user's real position.
*/
private val balanceFlow: Flow<Double?> = accountContext.flatMapLatest { (selected, accounts) ->
if (selected == null && accounts.size > 1) {
combine(
transactionRepository.getCombinedLatestBalance(),
transactionRepository.getLatestTransactionWithBalance(),
) { combined, latest ->
// While history is still unattributed the per-line sum is
// NULL — fall back to the single-figure behavior.
combined ?: latest?.balance
}
} else {
transactionRepository.getLatestTransactionWithBalance().map { it?.balance }
}
}
/**
* Fuliza is per-line. With a line selected, show that line's figures;
* on the Combined view with 2+ lines, sum each line's latest facts so
* line A's limit never gets crossed with line B's outstanding.
*/
private val fulizaFlow: Flow<FulizaFacts> = accountContext.flatMapLatest { (selected, accounts) ->
val lines: List<Long?> = when {
selected != null -> listOf(selected)
accounts.size > 1 -> accounts.map { it.id }
else -> listOf(null) // single/no line: unfiltered covers unattributed rows
}
val perLine = lines.map { id ->
combine(
transactionRepository.getLatestFulizaOutstandingFor(id),
transactionRepository.getLatestFulizaLimitFor(id),
) { outTx, limTx ->
// M-PESA's "Available Fuliza limit" is the headroom remaining
// AT THAT SMS (ceiling − outstanding then). So ceiling = that
// reading + the outstanding it was reported alongside. The
// amount borrowable NOW = ceiling − current outstanding.
val outstanding = outTx?.fulizaOutstanding
val ceiling = limTx?.let { (it.fulizaLimit ?: 0.0) + (it.fulizaOutstanding ?: 0.0) }
val available = ceiling?.let { (it - (outstanding ?: 0.0)).coerceAtLeast(0.0) }
FulizaFacts(
outstanding = outstanding,
outstandingAt = outTx?.timestamp,
ceiling = ceiling,
available = available,
)
}
}
combine(perLine) { facts ->
FulizaFacts(
outstanding = facts.mapNotNull { it.outstanding }.takeIf { it.isNotEmpty() }?.sum(),
outstandingAt = facts.mapNotNull { it.outstandingAt }.maxOrNull(),
ceiling = facts.mapNotNull { it.ceiling }.takeIf { it.isNotEmpty() }?.sum(),
available = facts.mapNotNull { it.available }.takeIf { it.isNotEmpty() }?.sum(),
)
}
}
private val timeRange = _selectedPeriod.flatMapLatest { period ->
val now = System.currentTimeMillis()
val start = when (period) {
@@ -121,13 +198,13 @@ class HomeViewModel @Inject constructor(
val uiState: StateFlow<HomeUiState> = combine(
_selectedPeriod,
transactionRepository.getLatestTransactionWithBalance(),
balanceFlow,
transactionRepository.getRecentTransactions(10),
categoryRepository.getAllCategories()
) { period, latestTransaction, recentTransactions, categories ->
) { period, balance, recentTransactions, categories ->
HomeUiState(
greeting = DateUtils.greeting(),
balance = latestTransaction?.balance,
balance = balance,
recentTransactions = recentTransactions,
selectedPeriod = period,
monthLabel = DateUtils.formatMonthYear(System.currentTimeMillis())
@@ -174,20 +251,13 @@ class HomeViewModel @Inject constructor(
state.copy(accounts = accounts)
}.combine(settingsRepository.getSelectedAccountId()) { state, accountId ->
state.copy(selectedAccountId = accountId)
}.combine(transactionRepository.getLatestFulizaOutstanding()) { state, tx ->
}.combine(fulizaFlow) { state, fuliza ->
state.copy(
fulizaOutstanding = tx?.fulizaOutstanding,
fulizaOutstandingAt = tx?.timestamp,
fulizaOutstanding = fuliza.outstanding,
fulizaOutstandingAt = fuliza.outstandingAt,
fulizaAvailable = fuliza.available,
fulizaCeiling = fuliza.ceiling,
)
}.combine(transactionRepository.getLatestFulizaLimit()) { state, tx ->
// M-PESA's "Available Fuliza limit" is the headroom remaining AT THAT
// SMS (ceiling − outstanding then). So ceiling = that reading + the
// outstanding it was reported alongside (0 after a full clear). The
// amount borrowable NOW = ceiling − current outstanding.
val ceiling = tx?.let { (it.fulizaLimit ?: 0.0) + (it.fulizaOutstanding ?: 0.0) }
val outstanding = state.fulizaOutstanding ?: 0.0
val available = ceiling?.let { (it - outstanding).coerceAtLeast(0.0) }
state.copy(fulizaAvailable = available, fulizaCeiling = ceiling)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeUiState())
fun selectPeriod(period: Period) {
@@ -66,7 +66,10 @@ fun OnboardingScreen(
onRequestPermission = {
val permissions = mutableListOf(
Manifest.permission.RECEIVE_SMS,
Manifest.permission.READ_SMS
Manifest.permission.READ_SMS,
// Lets Ledga name detected SIMs ("Safaricom") and
// keep accounts linked when a SIM changes slots.
Manifest.permission.READ_PHONE_STATE
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
@@ -427,6 +427,40 @@ class MpesaSmsParserTest {
assertEquals(TransactionDirection.OUTFLOW, t.direction)
}
@Test
fun `real kcb withdrawal — wallet balance wins over KCB account balance, direction is INFLOW`() {
// Real template: the KCB pocket balance comes FIRST, wallet second.
// Regression: parser stored 0.17 as the wallet balance and OUTFLOW
// as the direction (the SMS says "from YOUR KCB", not "from KCB").
val sms = "UF3IE6OMZP Confirmed. You have transfered Ksh15,000.00 from your KCB M-PESA account " +
"on 3/6/26 at 4:27 PM. KCB M-PESA Account balance is Ksh0.17. New M-PESA balance is Ksh15,000.00."
val t = assertSuccess(sms).transaction
assertEquals(TransactionType.KCB_MPESA, t.type)
assertEquals(15000.0, t.amount, 0.01)
assertEquals(15000.0, t.balance, 0.01)
assertEquals(TransactionDirection.INFLOW, t.direction)
}
@Test
fun `real kcb deposit — wallet balance first, savings balance ignored`() {
val sms = "UF1AB2CD34 Confirmed. Ksh5,000.00 transfered to KCB M-PESA account on 1/6/26 at 9:00 AM. " +
"New M-PESA balance is Ksh3,782.39, new KCB M-PESA Saving account balance is Ksh5,000.17."
val t = assertSuccess(sms).transaction
assertEquals(TransactionType.KCB_MPESA, t.type)
assertEquals(5000.0, t.amount, 0.01)
assertEquals(3782.39, t.balance, 0.01)
assertEquals(TransactionDirection.OUTFLOW, t.direction)
}
@Test
fun `mshwari withdrawal with possessive phrasing is INFLOW`() {
val sms = "RK31B7X4ZQ Confirmed. Ksh1,000.00 transferred from your M-Shwari account on 21/3/26 at 5:00 PM. " +
"M-Shwari account balance is Ksh200.00. New M-PESA balance is Ksh1,500.00."
val t = assertSuccess(sms).transaction
assertEquals(TransactionDirection.INFLOW, t.direction)
assertEquals(1500.0, t.balance, 0.01)
}
// --- Edge cases ---
@Test