add bml login, accounts and contacts
This commit is contained in:
@@ -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))
|
||||
|
||||
263
app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt
Normal file
263
app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt
Normal 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("\"", "\\\"")}\""
|
||||
}
|
||||
6
app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt
Normal file
6
app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt
Normal file
@@ -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) {
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user