From e4684ec017af370b32fc136bf983da0f53009e9e Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Thu, 14 May 2026 00:12:28 +0500 Subject: [PATCH] add bml login, accounts and contacts --- .../java/sh/sar/basedbank/BasedBankApp.kt | 3 + .../sh/sar/basedbank/api/bml/BmlLoginFlow.kt | 263 ++++++++++++++++++ .../sh/sar/basedbank/api/bml/BmlModels.kt | 6 + .../sar/basedbank/ui/home/AccountsAdapter.kt | 6 +- .../sh/sar/basedbank/ui/home/HomeActivity.kt | 137 +++++++-- .../ui/login/BankSelectionFragment.kt | 4 + .../basedbank/ui/login/CredentialsFragment.kt | 58 +++- .../sh/sar/basedbank/util/AccountCache.kt | 47 ++++ .../sh/sar/basedbank/util/ContactsCache.kt | 46 +++ .../sh/sar/basedbank/util/CredentialStore.kt | 29 ++ .../res/layout/fragment_bank_selection.xml | 44 +++ .../main/res/layout/fragment_credentials.xml | 3 + app/src/main/res/navigation/login_nav.xml | 20 +- app/src/main/res/values/strings.xml | 1 + 14 files changed, 634 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt create mode 100644 app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt diff --git a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt index faff505..df04edf 100644 --- a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt +++ b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt @@ -3,6 +3,7 @@ package sh.sar.basedbank import android.app.Application import androidx.appcompat.app.AppCompatDelegate import com.google.android.material.color.DynamicColors +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.api.mib.MibProfile @@ -15,6 +16,8 @@ class BasedBankApp : Application() { var fullName: String = "" var mibSession: MibSession? = null var mibProfiles: List = emptyList() + var bmlSession: BmlSession? = null + var bmlAccounts: List = emptyList() val mibLoginFlow by lazy { MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE)) 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 new file mode 100644 index 0000000..f377be7 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt @@ -0,0 +1,263 @@ +package sh.sar.basedbank.api.bml + +import android.net.Uri +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.FormBody +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.MediaType.Companion.toMediaType +import org.json.JSONArray +import org.json.JSONObject +import sh.sar.basedbank.api.mib.MibAccount +import sh.sar.basedbank.api.mib.MibBeneficiary +import sh.sar.basedbank.util.Totp +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 +import java.util.concurrent.TimeUnit + +class BmlLoginFlow { + + private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking" + private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7" + private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback" + private val APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android 14; 22101320I)" + private val APP_VERSION = "2.1.43.345" + private val WEB_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0" + + private val cookieStore = mutableMapOf>() + private val cookieJar = object : CookieJar { + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val list = cookieStore.getOrPut(url.host) { mutableListOf() } + for (c in cookies) { + list.removeAll { it.name == c.name } + list.add(c) + } + } + override fun loadForRequest(url: HttpUrl): List = + cookieStore[url.host] ?: emptyList() + } + + private val client = OkHttpClient.Builder() + .cookieJar(cookieJar) + .followRedirects(false) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private val apiClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + /** Full login: returns a BmlSession and the account list. */ + fun login(username: String, password: String, otpSeed: String): Pair> { + // Step 1: GET login page — seeds XSRF-TOKEN + blaze_session cookies + client.newCall( + Request.Builder().url("$BASE_URL/web/login") + .header("User-Agent", WEB_USER_AGENT).build() + ).execute().close() + + val xsrf = xsrfToken() ?: throw Exception("Could not fetch login page") + + // Step 2: POST credentials + val loginBody = """{"username":${quote(username)},"password":${quote(password)},"code":""}""" + .toRequestBody("application/json".toMediaType()) + val loginResp = client.newCall( + Request.Builder().url("$BASE_URL/web/login").post(loginBody) + .header("X-XSRF-TOKEN", xsrf) + .header("User-Agent", WEB_USER_AGENT).build() + ).execute() + loginResp.close() + if (loginResp.code != 302) throw Exception("Login failed — check your username/password") + + // Step 3: GET 2FA page (refreshes blaze_session) + client.newCall( + Request.Builder().url("$BASE_URL/web/login/2fa") + .header("X-XSRF-TOKEN", xsrf) + .header("User-Agent", WEB_USER_AGENT).build() + ).execute().close() + val xsrf2 = xsrfToken() ?: xsrf + + // Step 4: POST OTP + val otp = Totp.generate(otpSeed) + val twoFaBody = """{"code":${quote(otp)},"channel":"authenticator"}""" + .toRequestBody("application/json".toMediaType()) + val twoFaResp = client.newCall( + Request.Builder().url("$BASE_URL/web/login/2fa").post(twoFaBody) + .header("X-XSRF-TOKEN", xsrf2) + .header("User-Agent", WEB_USER_AGENT).build() + ).execute() + twoFaResp.close() + if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed") + + // Step 5: GET /web/profile (sets blaze_identity cookie for profile selection) + client.newCall( + Request.Builder().url("$BASE_URL/web/profile") + .header("X-XSRF-TOKEN", xsrf2) + .header("User-Agent", WEB_USER_AGENT).build() + ).execute().close() + + // Step 6: PKCE OAuth authorize → extract auth code + val codeVerifier = generateCodeVerifier() + val codeChallenge = generateCodeChallenge(codeVerifier) + val deviceId = generateDeviceId() + + val authorizeUrl = HttpUrl.Builder() + .scheme("https").host("www.bankofmaldives.com.mv") + .addPathSegments("internetbanking/oauth/authorize") + .addQueryParameter("redirect_uri", REDIRECT_URI) + .addQueryParameter("client_id", CLIENT_ID) + .addQueryParameter("response_type", "code") + .addQueryParameter("state", randomUrlSafe(16)) + .addQueryParameter("nonce", randomUrlSafe(12)) + .addQueryParameter("code_challenge", codeChallenge) + .addQueryParameter("code_challenge_method", "S256") + .addQueryParameter("Device-ID", deviceId) + .addQueryParameter("User-Agent", APP_USER_AGENT) + .addQueryParameter("x-app-version", APP_VERSION) + .build() + + val authorizeResp = client.newCall( + Request.Builder().url(authorizeUrl) + .header("User-Agent", WEB_USER_AGENT).build() + ).execute() + authorizeResp.close() + + val location = authorizeResp.header("Location") + ?: throw Exception("OAuth authorize did not redirect") + val authCode = Uri.parse(location).getQueryParameter("code") + ?: throw Exception("No auth code in OAuth redirect") + + // Step 7: Exchange auth code for access token + val tokenBody = FormBody.Builder() + .add("Device-ID", deviceId) + .add("code", authCode) + .add("grant_type", "authorization_code") + .add("User-Agent", APP_USER_AGENT) + .add("redirect_uri", REDIRECT_URI) + .add("code_verifier", codeVerifier) + .add("client_id", CLIENT_ID) + .add("x-app-version", APP_VERSION) + .build() + + val tokenResp = client.newCall( + Request.Builder().url("$BASE_URL/oauth/token").post(tokenBody) + .header("User-Agent", WEB_USER_AGENT).build() + ).execute() + val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response") + tokenResp.close() + + val tokenObj = JSONObject(tokenJson) + val accessToken = tokenObj.optString("access_token") + .takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed") + + val session = BmlSession(accessToken = accessToken, deviceId = deviceId) + val accounts = fetchAccounts(session) + return Pair(session, accounts) + } + + fun fetchAccounts(session: BmlSession): List { + val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute() + val json = resp.body?.string() ?: return emptyList() + resp.close() + return parseDashboard(json) + } + + fun fetchContacts(session: BmlSession): List { + val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute() + val json = resp.body?.string() ?: return emptyList() + resp.close() + return parseContacts(json) + } + + private fun apiRequest(session: BmlSession, url: String) = + Request.Builder().url(url) + .header("Authorization", "Bearer ${session.accessToken}") + .header("User-Agent", APP_USER_AGENT) + .header("x-app-version", APP_VERSION) + .build() + + private fun parseDashboard(json: String): List { + val root = JSONObject(json) + if (!root.optBoolean("success")) return emptyList() + val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList() + return (0 until dashboard.length()).map { i -> + val item = dashboard.getJSONObject(i) + val currency = item.optString("currency", "MVR") + val available = item.optDouble("availableBalance", 0.0) + MibAccount( + profileName = "Bank of Maldives", + profileType = "BML", + accountNumber = item.optString("account"), + accountBriefName = item.optString("alias"), + currencyName = currency, + accountTypeName = item.optString("product"), + availableBalance = "%.2f".format(available), + currentBalance = "%.2f".format(item.optDouble("ledgerBalance", 0.0)), + blockedAmount = "%.2f".format(item.optDouble("lockedAmount", 0.0)), + mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00", + statusDesc = item.optString("account_status", "Active"), + profileImageHash = null + ) + } + } + + private fun parseContacts(json: String): List { + val root = JSONObject(json) + if (!root.optBoolean("success")) return emptyList() + val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList() + val result = mutableListOf() + for (i in 0 until payload.length()) { + val item = payload.getJSONObject(i) + val account = item.optString("account", "") + if (account.isBlank()) continue + result.add(MibBeneficiary( + benefNo = "bml_${item.optInt("id")}", + benefName = item.optString("name"), + benefNickName = item.optString("alias", item.optString("name")), + benefAccount = account, + benefType = "I", + bankColor = "#0066A1", + benefBankName = "Bank of Maldives", + bankCode = "", + benefStatus = item.optString("status", "S"), + transferCyDesc = item.optString("currency", "MVR"), + customerImgHash = null, + benefCategoryId = "BML" + )) + } + return result + } + + private fun xsrfToken(): String? = + cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value + + private fun generateCodeVerifier(): String { + val bytes = ByteArray(72) + SecureRandom().nextBytes(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } + + private fun generateCodeChallenge(verifier: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(Charsets.US_ASCII)) + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) + } + + private fun randomUrlSafe(byteCount: Int): String { + val b = ByteArray(byteCount) + SecureRandom().nextBytes(b) + return Base64.getUrlEncoder().withoutPadding().encodeToString(b) + } + + private fun generateDeviceId(): String { + val bytes = ByteArray(8) + SecureRandom().nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun quote(s: String) = "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} 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 new file mode 100644 index 0000000..5fd25c5 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt @@ -0,0 +1,6 @@ +package sh.sar.basedbank.api.bml + +data class BmlSession( + val accessToken: String, + val deviceId: String +) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt index 13ceee0..2fee318 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt @@ -61,7 +61,11 @@ class AccountsAdapter(accounts: List) : RecyclerView.ViewHolder(binding.root) { fun bind(item: Item.Header) { binding.tvProfileName.text = item.profileName - binding.tvProfileType.text = if (item.profileType == "0") "Personal" else "Business" + binding.tvProfileType.text = when (item.profileType) { + "BML" -> "Bank of Maldives" + "0" -> "Personal" + else -> "Business" + } } } 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 2a90fce..5d31577 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 @@ -14,6 +14,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R +import sh.sar.basedbank.api.bml.BmlLoginFlow +import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.databinding.ActivityHomeBinding import sh.sar.basedbank.ui.login.LoginActivity @@ -63,30 +65,42 @@ class HomeActivity : AppCompatActivity() { // Load data val app = application as BasedBankApp - if (app.accounts.isNotEmpty()) { + if (app.accounts.isNotEmpty() || app.bmlAccounts.isNotEmpty()) { // Came from fresh manual login — accounts ready, rest fetched in background - viewModel.accounts.value = app.accounts - AccountCache.save(this, app.accounts) - val cached = FinancingCache.load(this) - if (cached.isNotEmpty()) viewModel.financing.value = cached - val cachedContacts = ContactsCache.loadContacts(this) - if (cachedContacts.isNotEmpty()) viewModel.contacts.value = cachedContacts - val cachedCats = ContactsCache.loadCategories(this) - if (cachedCats.isNotEmpty()) viewModel.contactCategories.value = cachedCats - refreshFinancing(app.mibSession, app.mibProfiles) - refreshContacts(app.mibSession, app.mibProfiles) - } else { - // Came from lock screen — show caches immediately, refresh everything in background - val cached = AccountCache.load(this) - if (cached.isNotEmpty()) viewModel.accounts.value = cached + val mibAccounts = app.accounts.filter { it.profileType != "BML" } + val merged = mibAccounts + app.bmlAccounts + viewModel.accounts.value = merged + if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts) + if (app.bmlAccounts.isNotEmpty()) AccountCache.saveBml(this, app.bmlAccounts) + val cachedFinancing = FinancingCache.load(this) if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing - val cachedContacts = ContactsCache.loadContacts(this) + val cachedContacts = mergeContacts(ContactsCache.loadContacts(this), ContactsCache.loadBml(this)) if (cachedContacts.isNotEmpty()) viewModel.contacts.value = cachedContacts val cachedCats = ContactsCache.loadCategories(this) if (cachedCats.isNotEmpty()) viewModel.contactCategories.value = cachedCats - val creds = CredentialStore(this).loadMibCredentials() - if (creds != null) autoRefresh(creds) + + refreshFinancing(app.mibSession, app.mibProfiles) + refreshContacts(app.mibSession, app.mibProfiles) + if (app.bmlSession != null) refreshBmlContacts(app) + } else { + // Came from lock screen — show caches immediately, refresh everything in background + val cachedMib = AccountCache.load(this) + val cachedBml = AccountCache.loadBml(this) + val merged = cachedMib + cachedBml + if (merged.isNotEmpty()) viewModel.accounts.value = merged + val cachedFinancing = FinancingCache.load(this) + if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing + val cachedContacts = mergeContacts(ContactsCache.loadContacts(this), ContactsCache.loadBml(this)) + if (cachedContacts.isNotEmpty()) viewModel.contacts.value = cachedContacts + val cachedCats = ContactsCache.loadCategories(this) + 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) } // Show dashboard on first create @@ -109,32 +123,96 @@ class HomeActivity : AppCompatActivity() { .commit() } - private fun autoRefresh(creds: CredentialStore.MibCredentials) { + private fun autoRefreshMib( + mibCreds: CredentialStore.MibCredentials, + bmlCreds: CredentialStore.BmlCredentials? + ) { 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 { - val accounts = withContext(Dispatchers.IO) { - flow.login(creds.username, creds.passwordHash, creds.otpSeed) + mibAccounts = withContext(Dispatchers.IO) { + flow.login(mibCreds.username, mibCreds.passwordHash, mibCreds.otpSeed) } val app = application as BasedBankApp - app.accounts = accounts + app.accounts = mibAccounts app.mibSession = flow.lastSession app.mibProfiles = flow.lastProfiles - AccountCache.save(this@HomeActivity, accounts) - viewModel.accounts.postValue(accounts) - } catch (_: Exception) { - // Keep cached data silently - } finally { - binding.refreshIndicator.visibility = View.GONE + 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 */ } } + viewModel.accounts.postValue(mibAccounts + bmlAccounts) val app = application as BasedBankApp refreshFinancing(app.mibSession, app.mibProfiles) refreshContacts(app.mibSession, app.mibProfiles) } } + private fun autoRefreshBml(bmlCreds: CredentialStore.BmlCredentials) { + binding.refreshIndicator.visibility = View.VISIBLE + 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 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 } + } + } + + private fun refreshBmlContacts(app: BasedBankApp) { + val session = app.bmlSession ?: return + val bmlFlow = BmlLoginFlow() + lifecycleScope.launch { + try { + val bmlContacts = withContext(Dispatchers.IO) { bmlFlow.fetchContacts(session) } + if (bmlContacts.isNotEmpty()) { + ContactsCache.saveBml(this@HomeActivity, bmlContacts) + val mibContacts = viewModel.contacts.value ?: ContactsCache.loadContacts(this@HomeActivity) + viewModel.contacts.postValue(mergeContacts(mibContacts, bmlContacts)) + } + } catch (_: Exception) { /* keep cached */ } + } + } + + private fun mergeContacts( + mib: List, + bml: List + ): List { + val seen = mutableSetOf() + val result = mutableListOf() + for (c in mib) if (seen.add(c.benefNo)) result.add(c) + for (c in bml) if (seen.add(c.benefNo)) result.add(c) + return result + } + private fun refreshContacts(session: MibSession?, profiles: List) { if (session == null || profiles.isEmpty()) return val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE) @@ -162,7 +240,8 @@ class HomeActivity : AppCompatActivity() { } if (allContacts.isNotEmpty()) { ContactsCache.save(this@HomeActivity, allContacts, allCategories) - viewModel.contacts.postValue(allContacts) + val bmlContacts = ContactsCache.loadBml(this@HomeActivity) + viewModel.contacts.postValue(mergeContacts(allContacts, bmlContacts)) viewModel.contactCategories.postValue(allCategories) } } catch (_: Exception) { /* keep cached data */ } diff --git a/app/src/main/java/sh/sar/basedbank/ui/login/BankSelectionFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/login/BankSelectionFragment.kt index 4b19ab7..eb910c1 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/login/BankSelectionFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/login/BankSelectionFragment.kt @@ -23,6 +23,10 @@ class BankSelectionFragment : Fragment() { binding.cardMib.setOnClickListener { findNavController().navigate(R.id.action_bankSelection_to_credentials) } + binding.cardBml.setOnClickListener { + val args = android.os.Bundle().apply { putString("bankType", "BML") } + findNavController().navigate(R.id.action_bankSelection_to_credentials_bml, args) + } } override fun onDestroyView() { 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 29c124b..618935e 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 @@ -9,6 +9,7 @@ import android.os.Handler import android.os.Looper import android.text.Editable import android.text.TextWatcher +import android.widget.Toast import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers @@ -16,6 +17,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import sh.sar.basedbank.util.Totp import sh.sar.basedbank.BasedBankApp +import sh.sar.basedbank.R +import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.util.AccountCache import sh.sar.basedbank.util.CredentialStore @@ -24,7 +27,8 @@ import sh.sar.basedbank.ui.home.HomeActivity class CredentialsFragment : Fragment() { - private val TAG = "CredentialsFragment" + private val bankType: String get() = arguments?.getString("bankType") ?: "MIB" + private val otpHandler = Handler(Looper.getMainLooper()) private val otpRunnable = object : Runnable { override fun run() { @@ -42,6 +46,11 @@ class CredentialsFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + if (bankType == "BML") { + binding.ivBankLogo.setImageResource(R.drawable.bml_logo_vector) + binding.tvSignInDesc.setText(R.string.bml_sign_in_desc) + } + binding.btnLogin.setOnClickListener { attemptLogin() } binding.etOtpSeed.addTextChangedListener(object : TextWatcher { @@ -82,11 +91,15 @@ class CredentialsFragment : Fragment() { } private fun attemptLogin() { + if (bankType == "BML") { + attemptBmlLogin() + return + } + val username = binding.etUsername.text.toString().trim() val password = binding.etPassword.text.toString() val otpSeed = binding.etOtpSeed.text.toString().trim() - if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) { binding.tvError.text = "Please fill in all fields" binding.tvError.visibility = View.VISIBLE @@ -125,6 +138,47 @@ class CredentialsFragment : Fragment() { } } + private fun attemptBmlLogin() { + val username = binding.etUsername.text.toString().trim() + val password = binding.etPassword.text.toString() + val otpSeed = binding.etOtpSeed.text.toString().trim() + + if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) { + binding.tvError.text = "Please fill in all fields" + binding.tvError.visibility = View.VISIBLE + return + } + + binding.tvError.visibility = View.GONE + binding.progressBar.visibility = View.VISIBLE + binding.btnLogin.isEnabled = false + + val flow = BmlLoginFlow() + viewLifecycleOwner.lifecycleScope.launch { + try { + val (session, accounts) = withContext(Dispatchers.IO) { + flow.login(username, password, otpSeed) + } + CredentialStore(requireContext()).saveBmlCredentials(username, password, otpSeed) + AccountCache.saveBml(requireContext(), accounts) + val app = requireActivity().application as BasedBankApp + app.bmlSession = session + app.bmlAccounts = accounts + // Merge with any existing MIB accounts already in app + app.accounts = app.accounts + accounts + val intent = Intent(requireContext(), HomeActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(intent) + } catch (e: Exception) { + binding.tvError.text = e.message ?: "Login failed" + binding.tvError.visibility = View.VISIBLE + } finally { + binding.progressBar.visibility = View.GONE + binding.btnLogin.isEnabled = true + } + } + } + override fun onDestroyView() { super.onDestroyView() _binding = null diff --git a/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt b/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt index 62b72b2..a0b639b 100644 --- a/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt @@ -9,6 +9,7 @@ object AccountCache { private const val PREFS = "account_cache" private const val KEY_MIB = "mib_accounts" + private const val KEY_BML = "bml_accounts" fun save(context: Context, accounts: List) { val arr = JSONArray() @@ -32,6 +33,52 @@ object AccountCache { .edit().putString(KEY_MIB, arr.toString()).apply() } + fun saveBml(context: Context, accounts: List) { + val arr = JSONArray() + for (acc in accounts) { + arr.put(JSONObject().apply { + put("profileName", acc.profileName) + put("profileType", acc.profileType) + put("accountNumber", acc.accountNumber) + put("accountBriefName", acc.accountBriefName) + put("currencyName", acc.currencyName) + put("accountTypeName", acc.accountTypeName) + put("availableBalance", acc.availableBalance) + put("currentBalance", acc.currentBalance) + put("blockedAmount", acc.blockedAmount) + put("mvrBalance", acc.mvrBalance) + put("statusDesc", acc.statusDesc) + }) + } + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY_BML, arr.toString()).apply() + } + + fun loadBml(context: Context): List { + val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_BML, null) ?: return emptyList() + return try { + val arr = JSONArray(json) + (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + MibAccount( + profileName = o.optString("profileName"), + profileType = o.optString("profileType"), + accountNumber = o.optString("accountNumber"), + accountBriefName = o.optString("accountBriefName"), + currencyName = o.optString("currencyName"), + accountTypeName = o.optString("accountTypeName"), + availableBalance = o.optString("availableBalance"), + currentBalance = o.optString("currentBalance"), + blockedAmount = o.optString("blockedAmount"), + mvrBalance = o.optString("mvrBalance"), + statusDesc = o.optString("statusDesc"), + profileImageHash = null + ) + } + } catch (e: Exception) { emptyList() } + } + fun load(context: Context): List { val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getString(KEY_MIB, null) ?: return emptyList() diff --git a/app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt b/app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt index 3437842..efdc3d0 100644 --- a/app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/ContactsCache.kt @@ -77,6 +77,52 @@ object ContactsCache { } } + fun saveBml(context: Context, contacts: List) { + val arr = JSONArray() + for (c in contacts) { + arr.put(JSONObject().apply { + put("benefNo", c.benefNo) + put("benefName", c.benefName) + put("benefNickName", c.benefNickName) + put("benefAccount", c.benefAccount) + put("benefType", c.benefType) + put("bankColor", c.bankColor) + put("benefBankName", c.benefBankName) + put("bankCode", c.bankCode) + put("benefStatus", c.benefStatus) + put("transferCyDesc", c.transferCyDesc) + put("benefCategoryId", c.benefCategoryId) + }) + } + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString("bml_contacts", arr.toString()).apply() + } + + fun loadBml(context: Context): List { + val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString("bml_contacts", null) ?: return emptyList() + return try { + val arr = JSONArray(json) + (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + MibBeneficiary( + benefNo = o.optString("benefNo"), + benefName = o.optString("benefName"), + benefNickName = o.optString("benefNickName"), + benefAccount = o.optString("benefAccount"), + benefType = o.optString("benefType"), + bankColor = o.optString("bankColor", "#0066A1"), + benefBankName = o.optString("benefBankName"), + bankCode = o.optString("bankCode"), + benefStatus = o.optString("benefStatus"), + transferCyDesc = o.optString("transferCyDesc", "MVR"), + customerImgHash = null, + benefCategoryId = o.optString("benefCategoryId", "BML") + ) + } + } catch (e: Exception) { emptyList() } + } + fun loadCategories(context: Context): List { val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getString(KEY_CATEGORIES, null) ?: return emptyList() 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 d6db1fb..8ac6360 100644 --- a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt @@ -17,8 +17,10 @@ class CredentialStore(context: Context) { private val transformation = "AES/GCM/NoPadding" data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String) + data class BmlCredentials(val username: String, val password: String, val otpSeed: String) fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username") + fun hasBmlCredentials(): Boolean = prefs.contains("bml_enc_username") fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) { val key = getOrCreateKey() @@ -53,6 +55,33 @@ class CredentialStore(context: Context) { .apply() } + fun saveBmlCredentials(username: String, password: String, otpSeed: String) { + val key = getOrCreateKey() + prefs.edit() + .putString("bml_enc_username", encrypt(username, key)) + .putString("bml_enc_password", encrypt(password, key)) + .putString("bml_enc_otp_seed", encrypt(otpSeed, key)) + .apply() + } + + fun loadBmlCredentials(): BmlCredentials? { + val key = getOrCreateKey() + val encUsername = prefs.getString("bml_enc_username", null) ?: return null + val encPassword = prefs.getString("bml_enc_password", null) ?: return null + val encSeed = prefs.getString("bml_enc_otp_seed", null) ?: return null + return try { + BmlCredentials(decrypt(encUsername, key), decrypt(encPassword, key), decrypt(encSeed, key)) + } catch (e: Exception) { null } + } + + fun clearBmlCredentials() { + prefs.edit() + .remove("bml_enc_username") + .remove("bml_enc_password") + .remove("bml_enc_otp_seed") + .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/res/layout/fragment_bank_selection.xml b/app/src/main/res/layout/fragment_bank_selection.xml index 925e202..714c688 100644 --- a/app/src/main/res/layout/fragment_bank_selection.xml +++ b/app/src/main/res/layout/fragment_bank_selection.xml @@ -73,5 +73,49 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_credentials.xml b/app/src/main/res/layout/fragment_credentials.xml index 9a8cc98..d6ac577 100644 --- a/app/src/main/res/layout/fragment_credentials.xml +++ b/app/src/main/res/layout/fragment_credentials.xml @@ -15,10 +15,12 @@ android:paddingBottom="32dp"> @@ -31,6 +33,7 @@ android:layout_marginBottom="8dp" /> + + + + + android:label="Sign In"> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 738a573..c062222 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,6 +21,7 @@ BML Internet Banking Sign In Enter your Maldives Islamic Bank credentials. + Enter your Bank of Maldives credentials. Username Password OTP Seed (TOTP Secret)