From ec2650747680451e967f763dc21df7425b39ec51 Mon Sep 17 00:00:00 2001 From: ngenohkevin Date: Wed, 10 Jun 2026 09:07:10 +0300 Subject: [PATCH] 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. --- app/build.gradle.kts | 4 +- .../main/java/com/ledga/app/MainActivity.kt | 58 +++++++++- .../ledga/app/data/db/dao/TransactionDao.kt | 43 +++++++- .../ledga/app/data/insights/InsightEngine.kt | 5 +- .../ledga/app/data/parser/MpesaSmsParser.kt | 20 +++- .../app/data/repository/AccountsRepository.kt | 84 ++++++++++++++ .../HistoricalBackfillRepository.kt | 76 +++---------- .../ledga/app/data/repository/MpesaInbox.kt | 74 +++++++++++++ .../app/data/repository/SettingsRepository.kt | 27 +++++ .../ledga/app/data/repository/SmsImporter.kt | 104 ++++++++++++------ .../data/repository/TransactionRepository.kt | 31 +++++- .../com/ledga/app/receiver/SmsReceiver.kt | 5 +- .../com/ledga/app/ui/home/HomeViewModel.kt | 100 ++++++++++++++--- .../app/ui/onboarding/OnboardingScreen.kt | 5 +- .../ledga/app/parser/MpesaSmsParserTest.kt | 34 ++++++ 15 files changed, 546 insertions(+), 124 deletions(-) create mode 100644 app/src/main/java/com/ledga/app/data/repository/MpesaInbox.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6819fd0..837d7ef 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" } diff --git a/app/src/main/java/com/ledga/app/MainActivity.kt b/app/src/main/java/com/ledga/app/MainActivity.kt index d4a4e61..5425992 100644 --- a/app/src/main/java/com/ledga/app/MainActivity.kt +++ b/app/src/main/java/com/ledga/app/MainActivity.kt @@ -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 = settingsRepository.getThemeMode() @@ -49,6 +66,45 @@ class MainViewModel @Inject constructor( val fontScale: StateFlow = 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 diff --git a/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt b/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt index 63ec07e..0857732 100644 --- a/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt +++ b/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt @@ -77,6 +77,16 @@ interface TransactionDao { """) suspend fun updateAccountForCodes(accountId: Long, codes: List): 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): Int + @Transaction @Query(""" SELECT * FROM transactions @@ -118,6 +128,30 @@ interface TransactionDao { """) fun getLatestTransactionWithBalance(accountId: Long? = null): Flow + /** + * 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 + /* * 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 + fun getLatestFulizaLimit(accountId: Long? = null): Flow @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 + fun getLatestFulizaOutstanding(accountId: Long? = null): Flow // ---- Own-account (transfer) recipient marking ---- diff --git a/app/src/main/java/com/ledga/app/data/insights/InsightEngine.kt b/app/src/main/java/com/ledga/app/data/insights/InsightEngine.kt index c983b83..9aac583 100644 --- a/app/src/main/java/com/ledga/app/data/insights/InsightEngine.kt +++ b/app/src/main/java/com/ledga/app/data/insights/InsightEngine.kt @@ -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 } diff --git a/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt b/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt index 1d48184..4458b72 100644 --- a/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt +++ b/app/src/main/java/com/ledga/app/data/parser/MpesaSmsParser.kt @@ -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) } } diff --git a/app/src/main/java/com/ledga/app/data/repository/AccountsRepository.kt b/app/src/main/java/com/ledga/app/data/repository/AccountsRepository.kt index 6aec85c..b5613e3 100644 --- a/app/src/main/java/com/ledga/app/data/repository/AccountsRepository.kt +++ b/app/src/main/java/com/ledga/app/data/repository/AccountsRepository.kt @@ -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 { + 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). diff --git a/app/src/main/java/com/ledga/app/data/repository/HistoricalBackfillRepository.kt b/app/src/main/java/com/ledga/app/data/repository/HistoricalBackfillRepository.kt index 3744449..4328c19 100644 --- a/app/src/main/java/com/ledga/app/data/repository/HistoricalBackfillRepository.kt +++ b/app/src/main/java/com/ledga/app/data/repository/HistoricalBackfillRepository.kt @@ -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 { - 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): List? = 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() - 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, - ) } diff --git a/app/src/main/java/com/ledga/app/data/repository/MpesaInbox.kt b/app/src/main/java/com/ledga/app/data/repository/MpesaInbox.kt new file mode 100644 index 0000000..c6faa97 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/repository/MpesaInbox.kt @@ -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 { + 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, since: Long): List? = 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() + 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 + } +} diff --git a/app/src/main/java/com/ledga/app/data/repository/SettingsRepository.kt b/app/src/main/java/com/ledga/app/data/repository/SettingsRepository.kt index dbb4eb9..1f7004d 100644 --- a/app/src/main/java/com/ledga/app/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/ledga/app/data/repository/SettingsRepository.kt @@ -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 = dataStore.data.map { prefs -> @@ -109,6 +118,24 @@ class SettingsRepository @Inject constructor( } } + // --- One-time SIM-attribution backfill --- + + fun isSimBackfillDone(): Flow = 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 = 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 = 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 = dataStore.data.map { prefs -> diff --git a/app/src/main/java/com/ledga/app/data/repository/SmsImporter.kt b/app/src/main/java/com/ledga/app/data/repository/SmsImporter.kt index d416a07..e5b9cd6 100644 --- a/app/src/main/java/com/ledga/app/data/repository/SmsImporter.kt +++ b/app/src/main/java/com/ledga/app/data/repository/SmsImporter.kt @@ -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, + 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() + 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 { - val messages = mutableListOf() - 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 + } } diff --git a/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt b/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt index 71e9c3f..109a106 100644 --- a/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt +++ b/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt @@ -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 = transactionDao.getLatestFulizaLimit() - fun getLatestFulizaOutstanding(): Flow = 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 = + selectedAccount.flatMapLatest { transactionDao.getLatestFulizaLimit(it) } + + fun getLatestFulizaOutstanding(): Flow = + selectedAccount.flatMapLatest { transactionDao.getLatestFulizaOutstanding(it) } + + /** Explicit-account variants — used to build the per-line sums on the Combined view. */ + fun getLatestFulizaLimitFor(accountId: Long?): Flow = + transactionDao.getLatestFulizaLimit(accountId) + + fun getLatestFulizaOutstandingFor(accountId: Long?): Flow = + transactionDao.getLatestFulizaOutstanding(accountId) + + /** Sum of every line's latest balance — the Combined view's headline figure. */ + fun getCombinedLatestBalance(): Flow = 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> = + transactionDao.getTransactions(startTime, endTime, null) /** Recipient fragments the user marked as their own accounts. */ fun getOwnAccountFragments(): Flow> = categoryRuleDao.getOwnAccountFragments() diff --git a/app/src/main/java/com/ledga/app/receiver/SmsReceiver.kt b/app/src/main/java/com/ledga/app/receiver/SmsReceiver.kt index a397ec1..0c11fe0 100644 --- a/app/src/main/java/com/ledga/app/receiver/SmsReceiver.kt +++ b/app/src/main/java/com/ledga/app/receiver/SmsReceiver.kt @@ -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 diff --git a/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt b/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt index afe767f..ec24432 100644 --- a/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/ledga/app/ui/home/HomeViewModel.kt @@ -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 = 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 = accountContext.flatMapLatest { (selected, accounts) -> + val lines: List = 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 = 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) { diff --git a/app/src/main/java/com/ledga/app/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/ledga/app/ui/onboarding/OnboardingScreen.kt index 4828c5e..7df2161 100644 --- a/app/src/main/java/com/ledga/app/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/ledga/app/ui/onboarding/OnboardingScreen.kt @@ -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) diff --git a/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt b/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt index e3655b2..8cabf3f 100644 --- a/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt +++ b/app/src/test/java/com/ledga/app/parser/MpesaSmsParserTest.kt @@ -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