add bml login, accounts and contacts

This commit is contained in:
2026-05-14 05:24:28 +05:00
parent 135f0659dd
commit e4684ec017
14 changed files with 634 additions and 33 deletions
@@ -3,6 +3,7 @@ package sh.sar.basedbank
import android.app.Application import android.app.Application
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import com.google.android.material.color.DynamicColors 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.MibAccount
import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.api.mib.MibProfile import sh.sar.basedbank.api.mib.MibProfile
@@ -15,6 +16,8 @@ class BasedBankApp : Application() {
var fullName: String = "" var fullName: String = ""
var mibSession: MibSession? = null var mibSession: MibSession? = null
var mibProfiles: List<MibProfile> = emptyList() var mibProfiles: List<MibProfile> = emptyList()
var bmlSession: BmlSession? = null
var bmlAccounts: List<MibAccount> = emptyList()
val mibLoginFlow by lazy { val mibLoginFlow by lazy {
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE)) MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE))
@@ -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<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
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<Cookie> =
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<BmlSession, List<MibAccount>> {
// 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<MibAccount> {
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<MibBeneficiary> {
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<MibAccount> {
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<MibBeneficiary> {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
val result = mutableListOf<MibBeneficiary>()
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("\"", "\\\"")}\""
}
@@ -0,0 +1,6 @@
package sh.sar.basedbank.api.bml
data class BmlSession(
val accessToken: String,
val deviceId: String
)
@@ -61,7 +61,11 @@ class AccountsAdapter(accounts: List<MibAccount>) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(item: Item.Header) { fun bind(item: Item.Header) {
binding.tvProfileName.text = item.profileName 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"
}
} }
} }
@@ -14,6 +14,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R 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.api.mib.MibLoginFlow
import sh.sar.basedbank.databinding.ActivityHomeBinding import sh.sar.basedbank.databinding.ActivityHomeBinding
import sh.sar.basedbank.ui.login.LoginActivity import sh.sar.basedbank.ui.login.LoginActivity
@@ -63,30 +65,42 @@ class HomeActivity : AppCompatActivity() {
// Load data // Load data
val app = application as BasedBankApp 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 // Came from fresh manual login — accounts ready, rest fetched in background
viewModel.accounts.value = app.accounts val mibAccounts = app.accounts.filter { it.profileType != "BML" }
AccountCache.save(this, app.accounts) val merged = mibAccounts + app.bmlAccounts
val cached = FinancingCache.load(this) viewModel.accounts.value = merged
if (cached.isNotEmpty()) viewModel.financing.value = cached if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts)
val cachedContacts = ContactsCache.loadContacts(this) if (app.bmlAccounts.isNotEmpty()) AccountCache.saveBml(this, app.bmlAccounts)
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 cachedFinancing = FinancingCache.load(this) val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing 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 if (cachedContacts.isNotEmpty()) viewModel.contacts.value = cachedContacts
val cachedCats = ContactsCache.loadCategories(this) val cachedCats = ContactsCache.loadCategories(this)
if (cachedCats.isNotEmpty()) viewModel.contactCategories.value = cachedCats 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 // Show dashboard on first create
@@ -109,32 +123,96 @@ class HomeActivity : AppCompatActivity() {
.commit() .commit()
} }
private fun autoRefresh(creds: CredentialStore.MibCredentials) { private fun autoRefreshMib(
mibCreds: CredentialStore.MibCredentials,
bmlCreds: CredentialStore.BmlCredentials?
) {
binding.refreshIndicator.visibility = View.VISIBLE binding.refreshIndicator.visibility = View.VISIBLE
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE) val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
val flow = MibLoginFlow(prefs) val flow = MibLoginFlow(prefs)
lifecycleScope.launch { lifecycleScope.launch {
var mibAccounts: List<MibAccount> = AccountCache.load(this@HomeActivity)
try { try {
val accounts = withContext(Dispatchers.IO) { mibAccounts = withContext(Dispatchers.IO) {
flow.login(creds.username, creds.passwordHash, creds.otpSeed) flow.login(mibCreds.username, mibCreds.passwordHash, mibCreds.otpSeed)
} }
val app = application as BasedBankApp val app = application as BasedBankApp
app.accounts = accounts app.accounts = mibAccounts
app.mibSession = flow.lastSession app.mibSession = flow.lastSession
app.mibProfiles = flow.lastProfiles app.mibProfiles = flow.lastProfiles
AccountCache.save(this@HomeActivity, accounts) AccountCache.save(this@HomeActivity, mibAccounts)
viewModel.accounts.postValue(accounts) } catch (_: Exception) { /* keep cached */ }
} catch (_: Exception) { finally { binding.refreshIndicator.visibility = View.GONE }
// Keep cached data silently
} finally { val bmlAccounts = AccountCache.loadBml(this@HomeActivity).toMutableList()
binding.refreshIndicator.visibility = View.GONE 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 val app = application as BasedBankApp
refreshFinancing(app.mibSession, app.mibProfiles) refreshFinancing(app.mibSession, app.mibProfiles)
refreshContacts(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<MibBeneficiary>,
bml: List<MibBeneficiary>
): List<MibBeneficiary> {
val seen = mutableSetOf<String>()
val result = mutableListOf<MibBeneficiary>()
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<MibProfile>) { private fun refreshContacts(session: MibSession?, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return if (session == null || profiles.isEmpty()) return
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE) val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
@@ -162,7 +240,8 @@ class HomeActivity : AppCompatActivity() {
} }
if (allContacts.isNotEmpty()) { if (allContacts.isNotEmpty()) {
ContactsCache.save(this@HomeActivity, allContacts, allCategories) 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) viewModel.contactCategories.postValue(allCategories)
} }
} catch (_: Exception) { /* keep cached data */ } } catch (_: Exception) { /* keep cached data */ }
@@ -23,6 +23,10 @@ class BankSelectionFragment : Fragment() {
binding.cardMib.setOnClickListener { binding.cardMib.setOnClickListener {
findNavController().navigate(R.id.action_bankSelection_to_credentials) 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() { override fun onDestroyView() {
@@ -9,6 +9,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.widget.Toast
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -16,6 +17,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.util.Totp import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.BasedBankApp 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.api.mib.MibLoginFlow
import sh.sar.basedbank.util.AccountCache import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
@@ -24,7 +27,8 @@ import sh.sar.basedbank.ui.home.HomeActivity
class CredentialsFragment : Fragment() { class CredentialsFragment : Fragment() {
private val TAG = "CredentialsFragment" private val bankType: String get() = arguments?.getString("bankType") ?: "MIB"
private val otpHandler = Handler(Looper.getMainLooper()) private val otpHandler = Handler(Looper.getMainLooper())
private val otpRunnable = object : Runnable { private val otpRunnable = object : Runnable {
override fun run() { override fun run() {
@@ -42,6 +46,11 @@ class CredentialsFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 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.btnLogin.setOnClickListener { attemptLogin() }
binding.etOtpSeed.addTextChangedListener(object : TextWatcher { binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
@@ -82,11 +91,15 @@ class CredentialsFragment : Fragment() {
} }
private fun attemptLogin() { private fun attemptLogin() {
if (bankType == "BML") {
attemptBmlLogin()
return
}
val username = binding.etUsername.text.toString().trim() val username = binding.etUsername.text.toString().trim()
val password = binding.etPassword.text.toString() val password = binding.etPassword.text.toString()
val otpSeed = binding.etOtpSeed.text.toString().trim() val otpSeed = binding.etOtpSeed.text.toString().trim()
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) { if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
binding.tvError.text = "Please fill in all fields" binding.tvError.text = "Please fill in all fields"
binding.tvError.visibility = View.VISIBLE 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() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
@@ -9,6 +9,7 @@ object AccountCache {
private const val PREFS = "account_cache" private const val PREFS = "account_cache"
private const val KEY_MIB = "mib_accounts" private const val KEY_MIB = "mib_accounts"
private const val KEY_BML = "bml_accounts"
fun save(context: Context, accounts: List<MibAccount>) { fun save(context: Context, accounts: List<MibAccount>) {
val arr = JSONArray() val arr = JSONArray()
@@ -32,6 +33,52 @@ object AccountCache {
.edit().putString(KEY_MIB, arr.toString()).apply() .edit().putString(KEY_MIB, arr.toString()).apply()
} }
fun saveBml(context: Context, accounts: List<MibAccount>) {
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<MibAccount> {
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<MibAccount> { fun load(context: Context): List<MibAccount> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_MIB, null) ?: return emptyList() .getString(KEY_MIB, null) ?: return emptyList()
@@ -77,6 +77,52 @@ object ContactsCache {
} }
} }
fun saveBml(context: Context, contacts: List<MibBeneficiary>) {
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<MibBeneficiary> {
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<MibBeneficiaryCategory> { fun loadCategories(context: Context): List<MibBeneficiaryCategory> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_CATEGORIES, null) ?: return emptyList() .getString(KEY_CATEGORIES, null) ?: return emptyList()
@@ -17,8 +17,10 @@ class CredentialStore(context: Context) {
private val transformation = "AES/GCM/NoPadding" private val transformation = "AES/GCM/NoPadding"
data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String) 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 hasMibCredentials(): Boolean = prefs.contains("mib_enc_username")
fun hasBmlCredentials(): Boolean = prefs.contains("bml_enc_username")
fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) { fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) {
val key = getOrCreateKey() val key = getOrCreateKey()
@@ -53,6 +55,33 @@ class CredentialStore(context: Context) {
.apply() .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 { private fun getOrCreateKey(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) } val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
ks.getKey(keyAlias, null)?.let { return it as SecretKey } ks.getKey(keyAlias, null)?.let { return it as SecretKey }
@@ -73,5 +73,49 @@
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- BML Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardBml"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<ImageView
android:layout_width="138dp"
android:layout_height="40dp"
android:src="@drawable/bml_logo_vector"
android:contentDescription="@string/bml_name"
android:scaleType="fitStart"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/bml_name"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/bml_desc"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="4dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
@@ -15,10 +15,12 @@
android:paddingBottom="32dp"> android:paddingBottom="32dp">
<ImageView <ImageView
android:id="@+id/ivBankLogo"
android:layout_width="138dp" android:layout_width="138dp"
android:layout_height="40dp" android:layout_height="40dp"
android:src="@drawable/mib_faisanet_logo" android:src="@drawable/mib_faisanet_logo"
android:contentDescription="@string/mib_name" android:contentDescription="@string/mib_name"
android:scaleType="fitStart"
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:layout_marginBottom="32dp" /> android:layout_marginBottom="32dp" />
@@ -31,6 +33,7 @@
android:layout_marginBottom="8dp" /> android:layout_marginBottom="8dp" />
<TextView <TextView
android:id="@+id/tvSignInDesc"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/sign_in_desc" android:text="@string/sign_in_desc"
+19 -1
View File
@@ -17,11 +17,29 @@
app:popEnterAnim="@anim/slide_in_left" app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right" /> app:popExitAnim="@anim/slide_out_right" />
<action
android:id="@+id/action_bankSelection_to_credentials_bml"
app:destination="@id/credentialsFragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right">
<argument
android:name="bankType"
app:argType="string"
android:defaultValue="MIB" />
</action>
</fragment> </fragment>
<fragment <fragment
android:id="@+id/credentialsFragment" android:id="@+id/credentialsFragment"
android:name="sh.sar.basedbank.ui.login.CredentialsFragment" android:name="sh.sar.basedbank.ui.login.CredentialsFragment"
android:label="Sign In" /> android:label="Sign In">
<argument
android:name="bankType"
app:argType="string"
android:defaultValue="MIB" />
</fragment>
</navigation> </navigation>
+1
View File
@@ -21,6 +21,7 @@
<string name="bml_desc">BML Internet Banking</string> <string name="bml_desc">BML Internet Banking</string>
<string name="sign_in">Sign In</string> <string name="sign_in">Sign In</string>
<string name="sign_in_desc">Enter your Maldives Islamic Bank credentials.</string> <string name="sign_in_desc">Enter your Maldives Islamic Bank credentials.</string>
<string name="bml_sign_in_desc">Enter your Bank of Maldives credentials.</string>
<string name="username">Username</string> <string name="username">Username</string>
<string name="password">Password</string> <string name="password">Password</string>
<string name="otp_seed">OTP Seed (TOTP Secret)</string> <string name="otp_seed">OTP Seed (TOTP Secret)</string>