diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt index 2e4c1d1..e623f50 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt @@ -19,6 +19,8 @@ import java.security.SecureRandom import java.util.Base64 import java.util.concurrent.TimeUnit +class AuthExpiredException : Exception("Session expired") + class BmlLoginFlow { private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking" @@ -162,9 +164,43 @@ class BmlLoginFlow { fun fetchAccounts(session: BmlSession): List { val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute() - val json = resp.body?.string() ?: return emptyList() + val code = resp.code + val json = resp.body?.string() resp.close() - return parseDashboard(json, "bml_${session.deviceId}") + if (code == 401 || code == 419) throw AuthExpiredException() + return parseDashboard(json ?: return emptyList(), "bml_${session.deviceId}") + } + + fun fetchForeignLimits(session: BmlSession): List { + val resp = apiClient.newCall( + Request.Builder().url("https://app.bankofmaldives.com.mv/api/v2/foreign-limits") + .header("Authorization", "Bearer ${session.accessToken}") + .header("User-Agent", APP_USER_AGENT) + .header("x-app-version", APP_VERSION) + .header("Accept", "application/json") + .build() + ).execute() + val code = resp.code + val json = resp.body?.string() + resp.close() + if (code == 401 || code == 419) throw AuthExpiredException() + return parseForeignLimits(json ?: return emptyList()) + } + + fun fetchUserInfo(session: BmlSession): String { + val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/userinfo")).execute() + val json = resp.body?.string() ?: return "" + resp.close() + return try { + val root = JSONObject(json) + if (!root.optBoolean("success")) return "" + val payload = root.optJSONObject("payload") ?: return "" + payload.optString("name").ifBlank { + payload.optString("fullName").ifBlank { + payload.optString("customer_name") + } + } + } catch (_: Exception) { "" } } fun fetchContacts(session: BmlSession): List { @@ -243,6 +279,37 @@ class BmlLoginFlow { return casaAccounts + prepaidCards } + private fun parseForeignLimits(json: String): List { + return try { + val root = JSONObject(json) + if (!root.optBoolean("success")) return emptyList() + val payload = root.optJSONArray("payload") ?: return emptyList() + (0 until payload.length()).map { i -> + val item = payload.getJSONObject(i) + val usage = item.optJSONObject("usageByCategory") ?: JSONObject() + val atm = usage.optJSONObject("ATM") ?: JSONObject() + val ecom = usage.optJSONObject("ECOM") ?: JSONObject() + val pos = usage.optJSONObject("POS") ?: JSONObject() + BmlForeignLimit( + type = item.optString("type", "Debit"), + used = item.optDouble("used", 0.0), + totalLimit = item.optDouble("totalLimit", 0.0), + generalCap = item.optDouble("generalCap", 0.0), + generalRemaining = item.optDouble("generalRemaining", 0.0), + medicalRemaining = item.optDouble("medicalRemaining", 0.0), + isAtmEnabled = item.optBoolean("isAtmEnabled", false), + isPosEnabled = item.optBoolean("isPosEnabled", false), + atmRemaining = atm.optDouble("remaining", 0.0), + atmLimit = atm.optDouble("limit", 0.0), + ecomRemaining = ecom.optDouble("remaining", 0.0), + ecomLimit = ecom.optDouble("limit", 0.0), + posRemaining = pos.optDouble("remaining", 0.0), + posLimit = pos.optDouble("limit", 0.0) + ) + } + } catch (_: Exception) { emptyList() } + } + private fun parseContacts(json: String): List { val root = JSONObject(json) if (!root.optBoolean("success")) return emptyList() diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt index 5fd25c5..ab44ba6 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt @@ -4,3 +4,20 @@ data class BmlSession( val accessToken: String, val deviceId: String ) + +data class BmlForeignLimit( + val type: String, + val used: Double, + val totalLimit: Double, + val generalCap: Double, + val generalRemaining: Double, + val medicalRemaining: Double, + val isAtmEnabled: Boolean, + val isPosEnabled: Boolean, + val atmRemaining: Double, + val atmLimit: Double, + val ecomRemaining: Double, + val ecomLimit: Double, + val posRemaining: Double, + val posLimit: Double +) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/DashboardFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/DashboardFragment.kt index e1ca3bf..d6c3675 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/DashboardFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/DashboardFragment.kt @@ -8,9 +8,11 @@ import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import sh.sar.basedbank.R +import sh.sar.basedbank.api.bml.BmlForeignLimit import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.databinding.FragmentDashboardBinding +import sh.sar.basedbank.databinding.ItemForeignLimitBinding class DashboardFragment : Fragment() { @@ -26,6 +28,7 @@ class DashboardFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) } viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) } + viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) } binding.btnTransfer.setOnClickListener { (requireActivity() as HomeActivity).showWithBackStack(TransferFragment()) @@ -52,6 +55,29 @@ class DashboardFragment : Fragment() { binding.tvUsdBalance.text = "USD %,.2f".format(usdTotal) } + private fun updateForeignLimits(entries: List) { + binding.containerForeignLimits.removeAllViews() + for (entry in entries) { + for (limit in entry.limits) { + val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false) + card.tvLimitUserName.text = entry.userName.ifBlank { "BML" } + card.tvLimitType.text = limit.type + card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap) + card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining) + card.tvLimitAtm.text = if (!limit.isAtmEnabled) + "USD %,.0f / %,.0f · Disabled".format(limit.atmRemaining, limit.atmLimit) + else + "USD %,.0f / %,.0f".format(limit.atmRemaining, limit.atmLimit) + card.tvLimitEcom.text = "USD %,.0f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit) + card.tvLimitPos.text = if (!limit.isPosEnabled) + "USD %,.0f / %,.0f · Disabled".format(limit.posRemaining, limit.posLimit) + else + "USD %,.0f / %,.0f".format(limit.posRemaining, limit.posLimit) + binding.containerForeignLimits.addView(card.root) + } + } + } + private fun updatePendingFinances(deals: List) { val total = deals.sumOf { it.outstandingAmount } binding.tvPendingFinances.text = "MVR %,.2f".format(total) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index 5d31577..74999b2 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -10,11 +10,14 @@ import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R +import sh.sar.basedbank.api.bml.AuthExpiredException import sh.sar.basedbank.api.bml.BmlLoginFlow +import sh.sar.basedbank.api.bml.BmlSession import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.databinding.ActivityHomeBinding @@ -30,6 +33,7 @@ import sh.sar.basedbank.util.AccountCache import sh.sar.basedbank.util.ContactsCache import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.FinancingCache +import sh.sar.basedbank.util.ForeignLimitsCache class HomeActivity : AppCompatActivity() { @@ -82,7 +86,10 @@ class HomeActivity : AppCompatActivity() { refreshFinancing(app.mibSession, app.mibProfiles) refreshContacts(app.mibSession, app.mibProfiles) - if (app.bmlSession != null) refreshBmlContacts(app) + if (app.bmlSession != null) { + refreshBmlContacts(app) + refreshBmlLimits(app.bmlSession!!) + } } else { // Came from lock screen — show caches immediately, refresh everything in background val cachedMib = AccountCache.load(this) @@ -97,10 +104,7 @@ class HomeActivity : AppCompatActivity() { if (cachedCats.isNotEmpty()) viewModel.contactCategories.value = cachedCats val store = CredentialStore(this) - val mibCreds = store.loadMibCredentials() - val bmlCreds = store.loadBmlCredentials() - if (mibCreds != null) autoRefreshMib(mibCreds, bmlCreds) - else if (bmlCreds != null) autoRefreshBml(bmlCreds) + autoRefresh(store.loadMibCredentials(), store.loadBmlCredentials(), store) } // Show dashboard on first create @@ -123,67 +127,99 @@ class HomeActivity : AppCompatActivity() { .commit() } - private fun autoRefreshMib( - mibCreds: CredentialStore.MibCredentials, - bmlCreds: CredentialStore.BmlCredentials? + private fun autoRefresh( + mibCreds: CredentialStore.MibCredentials?, + bmlCreds: CredentialStore.BmlCredentials?, + store: CredentialStore ) { + if (mibCreds == null && bmlCreds == null) return binding.refreshIndicator.visibility = View.VISIBLE - val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE) - val flow = MibLoginFlow(prefs) - lifecycleScope.launch { - var mibAccounts: List = AccountCache.load(this@HomeActivity) - try { - mibAccounts = withContext(Dispatchers.IO) { - flow.login(mibCreds.username, mibCreds.passwordHash, mibCreds.otpSeed) - } - val app = application as BasedBankApp - app.accounts = mibAccounts - app.mibSession = flow.lastSession - app.mibProfiles = flow.lastProfiles - AccountCache.save(this@HomeActivity, mibAccounts) - } catch (_: Exception) { /* keep cached */ } - finally { binding.refreshIndicator.visibility = View.GONE } - val bmlAccounts = AccountCache.loadBml(this@HomeActivity).toMutableList() - if (bmlCreds != null) { - try { - val bmlFlow = BmlLoginFlow() - val (session, accounts) = withContext(Dispatchers.IO) { - bmlFlow.login(bmlCreds.username, bmlCreds.password, bmlCreds.otpSeed) - } - val app = application as BasedBankApp - app.bmlSession = session - app.bmlAccounts = accounts - AccountCache.saveBml(this@HomeActivity, accounts) - bmlAccounts.clear() - bmlAccounts.addAll(accounts) - refreshBmlContacts(app) - } catch (_: Exception) { /* keep cached */ } + lifecycleScope.launch { + // MIB and BML login run in parallel + val mibJob = mibCreds?.let { + async(Dispatchers.IO) { + try { + val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE) + val flow = MibLoginFlow(prefs) + val accounts = flow.login(it.username, it.passwordHash, it.otpSeed) + val app = application as BasedBankApp + app.accounts = accounts + app.mibSession = flow.lastSession + app.mibProfiles = flow.lastProfiles + AccountCache.save(this@HomeActivity, accounts) + accounts + } catch (_: Exception) { AccountCache.load(this@HomeActivity) } + } } + + val bmlJob = bmlCreds?.let { + async(Dispatchers.IO) { + val bmlFlow = BmlLoginFlow() + val savedToken = store.loadBmlSession() + + // Try cached token first + if (savedToken != null) { + try { + val session = BmlSession(savedToken.first, savedToken.second) + val accounts = bmlFlow.fetchAccounts(session) + val app = application as BasedBankApp + app.bmlSession = session + app.bmlAccounts = accounts + AccountCache.saveBml(this@HomeActivity, accounts) + return@async Pair(session, accounts) + } catch (_: AuthExpiredException) { + // Token expired — fall through to full login + } catch (_: Exception) { + // Network or other error — fall through to full login + } + } + + // Full login (token missing or expired) + try { + val (session, accounts) = bmlFlow.login(it.username, it.password, it.otpSeed) + store.saveBmlSession(session.accessToken, session.deviceId) + val app = application as BasedBankApp + app.bmlSession = session + app.bmlAccounts = accounts + AccountCache.saveBml(this@HomeActivity, accounts) + Pair(session, accounts) + } catch (_: Exception) { + Pair(null, AccountCache.loadBml(this@HomeActivity)) + } + } + } + + val mibAccounts = mibJob?.await() ?: AccountCache.load(this@HomeActivity) + val (bmlSession, bmlAccounts) = bmlJob?.await() ?: Pair(null, AccountCache.loadBml(this@HomeActivity)) + viewModel.accounts.postValue(mibAccounts + bmlAccounts) + binding.refreshIndicator.visibility = View.GONE + val app = application as BasedBankApp + if (bmlSession != null) { + refreshBmlContacts(app) + refreshBmlLimits(bmlSession) + } refreshFinancing(app.mibSession, app.mibProfiles) refreshContacts(app.mibSession, app.mibProfiles) } } - private fun autoRefreshBml(bmlCreds: CredentialStore.BmlCredentials) { - binding.refreshIndicator.visibility = View.VISIBLE + private fun refreshBmlLimits(session: BmlSession) { + val bmlFlow = BmlLoginFlow() lifecycleScope.launch { - val cachedMib = AccountCache.load(this@HomeActivity) try { - val bmlFlow = BmlLoginFlow() - val (session, accounts) = withContext(Dispatchers.IO) { - bmlFlow.login(bmlCreds.username, bmlCreds.password, bmlCreds.otpSeed) + val (userName, limits) = withContext(Dispatchers.IO) { + Pair(bmlFlow.fetchUserInfo(session), bmlFlow.fetchForeignLimits(session)) } - val app = application as BasedBankApp - app.bmlSession = session - app.bmlAccounts = accounts - AccountCache.saveBml(this@HomeActivity, accounts) - viewModel.accounts.postValue(cachedMib + accounts) - refreshBmlContacts(app) - } catch (_: Exception) { /* keep cached */ } - finally { binding.refreshIndicator.visibility = View.GONE } + val existing = viewModel.bmlLimits.value?.toMutableList() ?: mutableListOf() + val idx = existing.indexOfFirst { it.userName == userName } + val entry = HomeViewModel.BmlLimitsData(userName, limits) + if (idx >= 0) existing[idx] = entry else existing.add(entry) + viewModel.bmlLimits.postValue(existing) + ForeignLimitsCache.save(this@HomeActivity, existing) + } catch (_: Exception) { /* keep previous */ } } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt index 81bd524..4400b7e 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt @@ -2,6 +2,7 @@ package sh.sar.basedbank.ui.home import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import sh.sar.basedbank.api.bml.BmlForeignLimit import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibBeneficiary import sh.sar.basedbank.api.mib.MibBeneficiaryCategory @@ -12,4 +13,7 @@ class HomeViewModel : ViewModel() { val financing = MutableLiveData>(emptyList()) val contacts = MutableLiveData>(emptyList()) val contactCategories = MutableLiveData>(emptyList()) + + data class BmlLimitsData(val userName: String, val limits: List) + val bmlLimits = MutableLiveData>(emptyList()) } diff --git a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt index 618935e..4e10396 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt @@ -159,7 +159,9 @@ class CredentialsFragment : Fragment() { val (session, accounts) = withContext(Dispatchers.IO) { flow.login(username, password, otpSeed) } - CredentialStore(requireContext()).saveBmlCredentials(username, password, otpSeed) + val store = CredentialStore(requireContext()) + store.saveBmlCredentials(username, password, otpSeed) + store.saveBmlSession(session.accessToken, session.deviceId) AccountCache.saveBml(requireContext(), accounts) val app = requireActivity().application as BasedBankApp app.bmlSession = session diff --git a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt index 8ac6360..ba324bb 100644 --- a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt @@ -82,6 +82,30 @@ class CredentialStore(context: Context) { .apply() } + fun saveBmlSession(accessToken: String, deviceId: String) { + val key = getOrCreateKey() + prefs.edit() + .putString("bml_enc_token", encrypt(accessToken, key)) + .putString("bml_enc_device_id", encrypt(deviceId, key)) + .apply() + } + + fun loadBmlSession(): Pair? { + val key = getOrCreateKey() + val encToken = prefs.getString("bml_enc_token", null) ?: return null + val encDeviceId = prefs.getString("bml_enc_device_id", null) ?: return null + return try { + Pair(decrypt(encToken, key), decrypt(encDeviceId, key)) + } catch (_: Exception) { null } + } + + fun clearBmlSession() { + prefs.edit() + .remove("bml_enc_token") + .remove("bml_enc_device_id") + .apply() + } + private fun getOrCreateKey(): SecretKey { val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) } ks.getKey(keyAlias, null)?.let { return it as SecretKey } diff --git a/app/src/main/java/sh/sar/basedbank/util/ForeignLimitsCache.kt b/app/src/main/java/sh/sar/basedbank/util/ForeignLimitsCache.kt new file mode 100644 index 0000000..17cd91e --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/util/ForeignLimitsCache.kt @@ -0,0 +1,76 @@ +package sh.sar.basedbank.util + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import sh.sar.basedbank.api.bml.BmlForeignLimit +import sh.sar.basedbank.ui.home.HomeViewModel + +object ForeignLimitsCache { + + private const val PREFS = "foreign_limits_cache" + private const val KEY = "bml_foreign_limits" + + fun save(context: Context, entries: List) { + val arr = JSONArray() + for (entry in entries) { + val limitsArr = JSONArray() + for (l in entry.limits) { + limitsArr.put(JSONObject().apply { + put("type", l.type) + put("used", l.used) + put("totalLimit", l.totalLimit) + put("generalCap", l.generalCap) + put("generalRemaining", l.generalRemaining) + put("medicalRemaining", l.medicalRemaining) + put("isAtmEnabled", l.isAtmEnabled) + put("isPosEnabled", l.isPosEnabled) + put("atmRemaining", l.atmRemaining) + put("atmLimit", l.atmLimit) + put("ecomRemaining", l.ecomRemaining) + put("ecomLimit", l.ecomLimit) + put("posRemaining", l.posRemaining) + put("posLimit", l.posLimit) + }) + } + arr.put(JSONObject().apply { + put("userName", entry.userName) + put("limits", limitsArr) + }) + } + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY, arr.toString()).apply() + } + + fun load(context: Context): List { + val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY, null) ?: return emptyList() + return try { + val arr = JSONArray(json) + (0 until arr.length()).map { i -> + val entry = arr.getJSONObject(i) + val limitsArr = entry.optJSONArray("limits") ?: JSONArray() + val limits = (0 until limitsArr.length()).map { j -> + val l = limitsArr.getJSONObject(j) + BmlForeignLimit( + type = l.optString("type", "Debit"), + used = l.optDouble("used", 0.0), + totalLimit = l.optDouble("totalLimit", 0.0), + generalCap = l.optDouble("generalCap", 0.0), + generalRemaining = l.optDouble("generalRemaining", 0.0), + medicalRemaining = l.optDouble("medicalRemaining", 0.0), + isAtmEnabled = l.optBoolean("isAtmEnabled", false), + isPosEnabled = l.optBoolean("isPosEnabled", false), + atmRemaining = l.optDouble("atmRemaining", 0.0), + atmLimit = l.optDouble("atmLimit", 0.0), + ecomRemaining = l.optDouble("ecomRemaining", 0.0), + ecomLimit = l.optDouble("ecomLimit", 0.0), + posRemaining = l.optDouble("posRemaining", 0.0), + posLimit = l.optDouble("posLimit", 0.0) + ) + } + HomeViewModel.BmlLimitsData(entry.optString("userName"), limits) + } + } catch (_: Exception) { emptyList() } + } +} diff --git a/app/src/main/res/layout/fragment_dashboard.xml b/app/src/main/res/layout/fragment_dashboard.xml index 5d0294e..bf13131 100644 --- a/app/src/main/res/layout/fragment_dashboard.xml +++ b/app/src/main/res/layout/fragment_dashboard.xml @@ -119,6 +119,13 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +