mirror of
https://github.com/andwati/ledga.git
synced 2026-11-01 09:52:15 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user