add bml login, accounts and contacts

This commit is contained in:
2026-05-14 00:12:28 +05:00
parent 135f0659dd
commit e4684ec017
14 changed files with 634 additions and 33 deletions

View File

@@ -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<MibProfile> = emptyList()
var bmlSession: BmlSession? = null
var bmlAccounts: List<MibAccount> = emptyList()
val mibLoginFlow by lazy {
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE))

View File

@@ -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("\"", "\\\"")}\""
}

View File

@@ -0,0 +1,6 @@
package sh.sar.basedbank.api.bml
data class BmlSession(
val accessToken: String,
val deviceId: String
)

View File

@@ -61,7 +61,11 @@ class AccountsAdapter(accounts: List<MibAccount>) :
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"
}
}
}

View File

@@ -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<MibAccount> = 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<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>) {
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 */ }

View File

@@ -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() {

View File

@@ -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

View File

@@ -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<MibAccount>) {
val arr = JSONArray()
@@ -32,6 +33,52 @@ object AccountCache {
.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> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_MIB, null) ?: return emptyList()

View File

@@ -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> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_CATEGORIES, null) ?: return emptyList()

View File

@@ -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 }

View File

@@ -73,5 +73,49 @@
</LinearLayout>
</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>
</ScrollView>

View File

@@ -15,10 +15,12 @@
android:paddingBottom="32dp">
<ImageView
android:id="@+id/ivBankLogo"
android:layout_width="138dp"
android:layout_height="40dp"
android:src="@drawable/mib_faisanet_logo"
android:contentDescription="@string/mib_name"
android:scaleType="fitStart"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="32dp" />
@@ -31,6 +33,7 @@
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/tvSignInDesc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/sign_in_desc"

View File

@@ -17,11 +17,29 @@
app:popEnterAnim="@anim/slide_in_left"
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
android:id="@+id/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>

View File

@@ -21,6 +21,7 @@
<string name="bml_desc">BML Internet Banking</string>
<string name="sign_in">Sign In</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="password">Password</string>
<string name="otp_seed">OTP Seed (TOTP Secret)</string>