working mib login and list accounts

This commit is contained in:
2026-05-12 04:19:52 +05:00
parent 31c8a5500d
commit 076a58359a
73 changed files with 3076 additions and 550 deletions
@@ -0,0 +1,17 @@
package sh.sar.basedbank
import android.app.Application
import com.google.android.material.color.DynamicColors
import sh.sar.basedbank.api.mib.MibAccount
class BasedBankApp : Application() {
// Held in memory after successful login; cleared on logout
var accounts: List<MibAccount> = emptyList()
var fullName: String = ""
override fun onCreate() {
super.onCreate()
DynamicColors.applyToActivitiesIfAvailable(this)
}
}
@@ -1,58 +1,19 @@
package sh.sar.basedbank
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.navigation.NavigationView
import androidx.navigation.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.navigateUp
import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
import androidx.drawerlayout.widget.DrawerLayout
import androidx.appcompat.app.AppCompatActivity
import sh.sar.basedbank.databinding.ActivityMainBinding
import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.ui.onboarding.OnboardingActivity
class MainActivity : AppCompatActivity() {
private lateinit var appBarConfiguration: AppBarConfiguration
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.appBarMain.toolbar)
binding.appBarMain.fab.setOnClickListener { view ->
Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
.setAction("Action", null)
.setAnchorView(R.id.fab).show()
}
val drawerLayout: DrawerLayout = binding.drawerLayout
val navView: NavigationView = binding.navView
val navController = findNavController(R.id.nav_host_fragment_content_main)
// Passing each menu ID as a set of Ids because each
// menu should be considered as top level destinations.
appBarConfiguration = AppBarConfiguration(
setOf(
R.id.nav_home, R.id.nav_gallery, R.id.nav_slideshow
), drawerLayout
)
setupActionBarWithNavController(navController, appBarConfiguration)
navView.setupWithNavController(navController)
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val onboardingDone = prefs.getBoolean("onboarding_done", false)
val target = if (onboardingDone) LoginActivity::class.java else OnboardingActivity::class.java
startActivity(Intent(this, target))
finish()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
// Inflate the menu; this adds items to the action bar if it is present.
menuInflater.inflate(R.menu.main, menu)
return true
}
override fun onSupportNavigateUp(): Boolean {
val navController = findNavController(R.id.nav_host_fragment_content_main)
return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
}
}
}
@@ -0,0 +1,50 @@
package sh.sar.basedbank.api.mib
import android.util.Base64
import org.json.JSONObject
import java.math.BigInteger
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
object MibCrypto {
const val DEFAULT_KEY = "8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678"
private val A = BigInteger(
"1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577"
)
private val P = BigInteger(
"2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919"
)
// cmod = G^A mod P (sent in every DH key exchange request)
val CMOD: String = BigInteger.TWO.modPow(A, P).toString()
fun deriveSessionKey(smod: String): String {
val shared = BigInteger(smod).modPow(A, P)
val sha256hex = MessageDigest.getInstance("SHA-256")
.digest(shared.toString().toByteArray())
.joinToString("") { "%02X".format(it) }
val keyBytes = sha256hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
return Base64.encodeToString(keyBytes, Base64.NO_WRAP)
}
fun encrypt(json: JSONObject, key: String): String {
val plaintext = json.toString().toByteArray(Charsets.UTF_8)
val keyBytes = key.toByteArray(Charsets.ISO_8859_1)
val cipher = Cipher.getInstance("Blowfish/ECB/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyBytes, "Blowfish"))
val ct = cipher.doFinal(plaintext)
return Base64.encodeToString(ct, Base64.NO_WRAP)
}
fun decrypt(ciphertextB64: String, key: String): JSONObject {
val keyBytes = key.toByteArray(Charsets.ISO_8859_1)
val ct = Base64.decode(ciphertextB64, Base64.DEFAULT)
val cipher = Cipher.getInstance("Blowfish/ECB/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(keyBytes, "Blowfish"))
val plaintext = cipher.doFinal(ct)
return JSONObject(String(plaintext, Charsets.UTF_8))
}
}
@@ -0,0 +1,302 @@
package sh.sar.basedbank.api.mib
import android.util.Log
import okhttp3.FormBody
import sh.sar.basedbank.util.Totp
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import kotlin.random.Random
class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
private val TAG = "MibLoginFlow"
private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.addInterceptor { chain ->
val req = chain.request().newBuilder()
.header("User-Agent", "android/1.0")
.header("Accept", "application/json")
.header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
.build()
chain.proceed(req)
}
.build()
// ─── Public entry point ───────────────────────────────────────────────────
/**
* Full login flow. Automatically handles first-time device registration
* vs. subsequent logins using stored key1/key2.
*
* Returns list of accounts from all profiles on success.
*/
fun login(username: String, password: String, otpSeed: String): List<MibAccount> {
val appId = getOrCreateAppId()
Log.d(TAG, "login: appId=$appId")
val key1 = prefs.getString("mib_key1_$username", null)
val key2 = prefs.getString("mib_key2_$username", null)
Log.d(TAG, "login: stored keys present=${key1 != null && key2 != null}")
return if (key1 != null && key2 != null) {
Log.d(TAG, "login: taking regular login path")
regularLogin(username, password, appId, key1, key2)
} else {
Log.d(TAG, "login: taking first-time registration path")
firstTimeRegistration(username, password, otpSeed, appId)
}
}
// ─── First-time registration ──────────────────────────────────────────────
private fun firstTimeRegistration(
username: String, password: String, otpSeed: String, appId: String
): List<MibAccount> {
Log.d(TAG, "[reg] step 0: key exchange (sfunc=r)")
val (session1, _) = initialKeyExchange(appId, MibCrypto.DEFAULT_KEY, "r")
Log.d(TAG, "[reg] step 0 done: xxid=${session1.xxid}")
Log.d(TAG, "[reg] step 1: getAuthType (A44)")
val userSalt = getAuthType(session1, username)
Log.d(TAG, "[reg] step 1 done: userSalt length=${userSalt.length}")
Log.d(TAG, "[reg] step 2: registration init (C41)")
Log.d(TAG, "[reg] username='$username' password='$password' userSalt='$userSalt'")
val clientSalt = randomAlpha(32)
val pgf03 = computePgf03(password, userSalt, clientSalt)
Log.d(TAG, "[reg] pgf03=$pgf03")
val regInitPayload = baseData(session1, "C41").apply {
put("uname", username)
put("pgf03", pgf03)
put("clientSalt", clientSalt)
}
val regInitResp = doRequest(session1, regInitPayload, "n")
Log.d(TAG, "[reg] step 2 response: $regInitResp")
check(regInitResp.optBoolean("success", false)) {
regInitResp.optString("reasonText", "Registration init failed")
}
Log.d(TAG, "[reg] step 3: OTP verify (C42)")
val otp = generateOtp(otpSeed)
Log.d(TAG, "[reg] generated OTP=$otp")
val otpPayload = baseData(session1, "C42").apply {
put("otp", otp)
put("uname", username)
put("otpType", "3")
}
val otpResp = doRequest(session1, otpPayload, "n")
Log.d(TAG, "[reg] step 3 response: $otpResp")
check(otpResp.optBoolean("success", false)) {
otpResp.optString("reasonText", "OTP verification failed")
}
val keyData = otpResp.getJSONArray("data").getJSONObject(0)
val key1 = keyData.getString("key1")
val key2 = keyData.getString("key2")
Log.d(TAG, "[reg] stored key1/key2 for user=$username")
prefs.edit().putString("mib_key1_$username", key1).putString("mib_key2_$username", key2).apply()
return regularLogin(username, password, appId, key1, key2)
}
// ─── Regular login ────────────────────────────────────────────────────────
private fun regularLogin(
username: String, password: String,
appId: String, key1: String, key2: String
): List<MibAccount> {
Log.d(TAG, "[login] step 4: key exchange (sfunc=i)")
val (session2, _) = initialKeyExchange(appId, key1, "i", key2)
Log.d(TAG, "[login] step 4 done: xxid=${session2.xxid}")
Log.d(TAG, "[login] step 5: getAuthType (A44)")
val userSalt = getAuthType(session2, username)
Log.d(TAG, "[login] step 5 done: userSalt length=${userSalt.length}")
Log.d(TAG, "[login] step 6: login init (A41)")
val clientSalt = randomAlpha(32)
val pgf03 = computePgf03(password, userSalt, clientSalt)
Log.d(TAG, "[login] pgf03 length=${pgf03.length}")
val loginPayload = baseData(session2, "A41").apply {
put("uname", username)
put("pgf03", pgf03)
put("clientSalt", clientSalt)
put("pmodTime", 0)
put("requireBankData", 1)
}
val loginResp = doRequest(session2, loginPayload, "n")
Log.d(TAG, "[login] step 6 response: success=${loginResp.optBoolean("success")} reasonCode=${loginResp.optString("reasonCode")} reasonText=${loginResp.optString("reasonText")}")
check(loginResp.optBoolean("success", false)) {
loginResp.optString("reasonText", "Login init failed")
}
val profiles = parseProfiles(loginResp)
Log.d(TAG, "[login] parsed ${profiles.size} profiles")
Log.d(TAG, "[login] step 7: fetch all profiles")
return fetchAllProfiles(session2, profiles)
}
// ─── Helpers ─────────────────────────────────────────────────────────────
private fun initialKeyExchange(
appId: String, encKey: String, sfunc: String, key2: String? = null
): Pair<MibSession, String> {
val innerPayload = JSONObject().apply {
put("cmod", MibCrypto.CMOD)
put("appId", appId)
put("routePath", "S40")
put("sodium", MibNonce.randomSodium())
put("xxid", MibNonce.randomXxid())
}
val encrypted = MibCrypto.encrypt(innerPayload, encKey)
val formBody = FormBody.Builder()
.add("sfunc", sfunc)
.apply { if (key2 != null) add("key2", key2) }
.add("data", encrypted)
.build()
val response = post(formBody)
Log.d(TAG, "keyExchange($sfunc) raw response (first 80): ${response.take(80)}")
val respJson = MibCrypto.decrypt(response, encKey)
Log.d(TAG, "keyExchange($sfunc) decrypted: success=${respJson.optBoolean("success")} reasonText=${respJson.optString("reasonText")}")
check(respJson.optBoolean("success", false)) {
respJson.optString("reasonText", "Key exchange failed")
}
val smod = respJson.getString("smod")
val sessionKey = MibCrypto.deriveSessionKey(smod)
val xxid = respJson.getString("xxid")
val nonceGen = respJson.getString("nonceGenerator")
return Pair(MibSession(appId, xxid, nonceGen, sessionKey), xxid)
}
private fun getAuthType(session: MibSession, username: String): String {
val payload = baseData(session, "A44").apply {
put("uname", username)
}
val resp = doRequest(session, payload, "n")
return resp.getJSONArray("data").getJSONObject(0).getString("userSalt")
}
private fun doRequest(session: MibSession, data: JSONObject, sfunc: String): JSONObject {
val routePath = data.optString("routePath", "?")
Log.d(TAG, "doRequest: routePath=$routePath xxid=${session.xxid.take(16)}...")
val encrypted = MibCrypto.encrypt(data, session.sessionKey)
val formBody = FormBody.Builder()
.add("xxid", session.xxid)
.add("sfunc", sfunc)
.add("data", encrypted)
.build()
val response = post(formBody)
Log.d(TAG, "doRequest($routePath) raw response (first 80): ${response.take(80)}")
val result = MibCrypto.decrypt(response, session.sessionKey)
Log.d(TAG, "doRequest($routePath) decrypted: success=${result.optBoolean("success")} reasonText=${result.optString("reasonText")}")
return result
}
private fun baseData(session: MibSession, routePath: String): JSONObject = JSONObject().apply {
put("nonce", MibNonce.generate(session.nonceGenerator))
put("appId", session.appId)
put("sodium", MibNonce.randomSodium())
put("routePath", routePath)
put("xxid", session.xxid)
}
private fun fetchAllProfiles(session: MibSession, profiles: List<MibProfile>): List<MibAccount> {
val allAccounts = mutableListOf<MibAccount>()
for (profile in profiles) {
val payload = baseData(session, "P47").apply {
put("profileType", profile.profileType)
put("profileId", profile.profileId)
}
val resp = doRequest(session, payload, "n")
if (!resp.optBoolean("success", false)) {
Log.w(TAG, "P47 failed for profile ${profile.name}: ${resp.optString("reasonText")}")
continue
}
val accountBalances = resp.optJSONArray("accountBalance") ?: continue
for (i in 0 until accountBalances.length()) {
val a = accountBalances.getJSONObject(i)
allAccounts.add(
MibAccount(
profileName = profile.name,
profileType = profile.profileType,
accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"),
accountTypeName = a.optString("accountTypeName"),
availableBalance = a.optString("availableBalance"),
currentBalance = a.optString("currentBalance"),
blockedAmount = a.optString("blockedAmount"),
mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc")
)
)
}
}
return allAccounts
}
private fun parseProfiles(loginResp: JSONObject): List<MibProfile> {
val arr = loginResp.optJSONArray("operatingProfiles") ?: return emptyList()
return (0 until arr.length()).map { i ->
val p = arr.getJSONObject(i)
MibProfile(
profileId = p.optString("profileId"),
customerProfileId = p.optString("customerProfileId"),
annexId = p.optString("annexId"),
customerId = p.optString("customerId"),
name = p.optString("name"),
cifType = p.optString("cifType"),
profileType = p.optString("profileType"),
color = p.optString("color")
)
}
}
private fun post(body: FormBody): String {
val request = Request.Builder()
.url(BASE_URL)
.post(body)
.build()
val response = client.newCall(request).execute()
return response.body?.string() ?: throw IllegalStateException("Empty response body")
}
private fun computePgf03(password: String, userSalt: String, clientSalt: String): String {
fun sha256Upper(input: String) = MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray())
.joinToString("") { "%02X".format(it) }
val h1 = sha256Upper(password)
val h2 = sha256Upper(h1 + userSalt)
return sha256Upper(clientSalt + h2)
}
private fun generateOtp(seed: String): String = Totp.generate(seed)
private fun getOrCreateAppId(): String {
var id = prefs.getString("mib_app_id", null)
if (id == null) {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
prefs.edit().putString("mib_app_id", id).apply()
}
return id
}
private fun randomAlpha(length: Int): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
return (1..length).map { chars[Random.nextInt(chars.length)] }.joinToString("")
}
}
@@ -0,0 +1,33 @@
package sh.sar.basedbank.api.mib
data class MibSession(
val appId: String,
val xxid: String,
val nonceGenerator: String,
val sessionKey: String
)
data class MibProfile(
val profileId: String,
val customerProfileId: String,
val annexId: String,
val customerId: String,
val name: String,
val cifType: String,
val profileType: String,
val color: String
)
data class MibAccount(
val profileName: String,
val profileType: String,
val accountNumber: String,
val accountBriefName: String,
val currencyName: String,
val accountTypeName: String,
val availableBalance: String,
val currentBalance: String,
val blockedAmount: String,
val mvrBalance: String,
val statusDesc: String
)
@@ -0,0 +1,79 @@
package sh.sar.basedbank.api.mib
import kotlin.math.floor
import kotlin.random.Random
object MibNonce {
/**
* Generate a nonce string from the nonceGenerator returned by the DH key exchange.
*
* Phase 1: for each group, take the first token's number * random(1-99), pad to 5 digits.
* Collect lastTwo (last 2 digits), digitSum (sum of all digits), cumSum.
* Phase 2: for each group, tokens 1-7 compute values using the operation letter.
* Each result's last 2 digits become the nonce digit and carry for next step.
*
* Operations: M=(carry%num)+ds+cs, A=carry+num+ds+cs, S=(carry^2)+num+ds+cs,
* X=(carry*num)+ds+cs, C=(carry^3)+num+ds+cs
*/
fun generate(nonceGenerator: String): String {
val groups = nonceGenerator.split("-")
val paddedList = mutableListOf<String>()
val lastTwoList = mutableListOf<Int>()
val digitSumList = mutableListOf<Int>()
var cumSum = 0
// Phase 1
for (group in groups) {
val tokens = group.trim().split(" ")
val n = tokens[0].filter { it.isDigit() }.toInt()
val r = floor(Random.nextDouble() * 99).toInt() + 1
val product = n * r
val padded = product.toString().padStart(5, '0')
val ds = padded.sumOf { it.digitToInt() }
val lt = padded.takeLast(2).toInt()
paddedList.add(padded)
lastTwoList.add(lt)
digitSumList.add(ds)
cumSum += ds
}
// Phase 2
val resultGroups = mutableListOf<String>()
for ((i, group) in groups.withIndex()) {
val tokens = group.trim().split(" ")
var carry = lastTwoList[i]
val ds = digitSumList[i]
val nonceDigits = mutableListOf<Int>()
for (j in 1..7) {
val token = tokens[j]
val op = token.filter { it.isLetter() }
val num = token.filter { it.isDigit() }.toInt()
val value: Long = when (op) {
"M" -> (carry % num).toLong() + ds + cumSum
"A" -> carry.toLong() + num + ds + cumSum
"S" -> (carry.toLong() * carry) + num + ds + cumSum
"X" -> carry.toLong() * num + ds + cumSum
"C" -> (carry.toLong() * carry * carry) + num + ds + cumSum
else -> 0L
}
val digit = value.toString().takeLast(2).toInt()
nonceDigits.add(digit)
carry = digit
}
val groupStr = paddedList[i] + " " + nonceDigits.joinToString(" ") {
it.toString().padStart(2, '0')
}
resultGroups.add(groupStr)
}
return resultGroups.joinToString("-")
}
fun randomSodium(): String = (Random.nextLong(1_000_000L, 16_000_000L)).toString()
fun randomXxid(): String = Random.nextLong(0L, (1L shl 40)).toString()
}
@@ -1,42 +0,0 @@
package sh.sar.basedbank.ui.gallery
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import sh.sar.basedbank.databinding.FragmentGalleryBinding
class GalleryFragment : Fragment() {
private var _binding: FragmentGalleryBinding? = null
// This property is only valid between onCreateView and
// onDestroyView.
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val galleryViewModel =
ViewModelProvider(this).get(GalleryViewModel::class.java)
_binding = FragmentGalleryBinding.inflate(inflater, container, false)
val root: View = binding.root
val textView: TextView = binding.textGallery
galleryViewModel.text.observe(viewLifecycleOwner) {
textView.text = it
}
return root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
@@ -1,13 +0,0 @@
package sh.sar.basedbank.ui.gallery
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class GalleryViewModel : ViewModel() {
private val _text = MutableLiveData<String>().apply {
value = "This is gallery Fragment"
}
val text: LiveData<String> = _text
}
@@ -0,0 +1,75 @@
package sh.sar.basedbank.ui.home
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.databinding.ItemAccountBinding
import sh.sar.basedbank.databinding.ItemProfileHeaderBinding
class AccountsAdapter(private val accounts: List<MibAccount>) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
data class Header(val profileName: String, val profileType: String) : Item()
data class Account(val account: MibAccount) : Item()
}
private val items: List<Item> = buildList {
var lastProfile = ""
for (account in accounts) {
if (account.profileName != lastProfile) {
add(Item.Header(account.profileName, account.profileType))
lastProfile = account.profileName
}
add(Item.Account(account))
}
}
override fun getItemViewType(position: Int) = when (items[position]) {
is Item.Header -> TYPE_HEADER
is Item.Account -> TYPE_ACCOUNT
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return if (viewType == TYPE_HEADER) {
HeaderViewHolder(ItemProfileHeaderBinding.inflate(inflater, parent, false))
} else {
AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = items[position]) {
is Item.Header -> (holder as HeaderViewHolder).bind(item)
is Item.Account -> (holder as AccountViewHolder).bind(item.account)
}
}
override fun getItemCount() = items.size
private inner class HeaderViewHolder(private val binding: ItemProfileHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: Item.Header) {
binding.tvProfileName.text = item.profileName
binding.tvProfileType.text = if (item.profileType == "0") "Personal" else "Business"
}
}
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount) {
binding.tvAccountName.text = account.accountBriefName
binding.tvAccountNumber.text = account.accountNumber
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
binding.tvAccountType.text = account.accountTypeName
binding.tvStatus.text = account.statusDesc
}
}
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_ACCOUNT = 1
}
}
@@ -0,0 +1,26 @@
package sh.sar.basedbank.ui.home
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.databinding.ActivityHomeBinding
class HomeActivity : AppCompatActivity() {
private lateinit var binding: ActivityHomeBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityHomeBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
val accounts = (application as BasedBankApp).accounts
val adapter = AccountsAdapter(accounts)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.adapter = adapter
}
}
@@ -1,42 +0,0 @@
package sh.sar.basedbank.ui.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import sh.sar.basedbank.databinding.FragmentHomeBinding
class HomeFragment : Fragment() {
private var _binding: FragmentHomeBinding? = null
// This property is only valid between onCreateView and
// onDestroyView.
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val homeViewModel =
ViewModelProvider(this).get(HomeViewModel::class.java)
_binding = FragmentHomeBinding.inflate(inflater, container, false)
val root: View = binding.root
val textView: TextView = binding.textHome
homeViewModel.text.observe(viewLifecycleOwner) {
textView.text = it
}
return root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
@@ -1,13 +0,0 @@
package sh.sar.basedbank.ui.home
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class HomeViewModel : ViewModel() {
private val _text = MutableLiveData<String>().apply {
value = "This is home Fragment"
}
val text: LiveData<String> = _text
}
@@ -0,0 +1,32 @@
package sh.sar.basedbank.ui.login
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentBankSelectionBinding
class BankSelectionFragment : Fragment() {
private var _binding: FragmentBankSelectionBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentBankSelectionBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.cardMib.setOnClickListener {
findNavController().navigate(R.id.action_bankSelection_to_credentials)
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
@@ -0,0 +1,129 @@
package sh.sar.basedbank.ui.login
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.os.Handler
import android.os.Looper
import android.text.Editable
import android.text.TextWatcher
import android.util.Log
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
import sh.sar.basedbank.ui.home.HomeActivity
class CredentialsFragment : Fragment() {
private val TAG = "CredentialsFragment"
private val otpHandler = Handler(Looper.getMainLooper())
private val otpRunnable = object : Runnable {
override fun run() {
updateOtpDisplay()
otpHandler.postDelayed(this, 1000)
}
}
private var _binding: FragmentCredentialsBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.btnLogin.setOnClickListener { attemptLogin() }
binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) { updateOtpDisplay() }
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
})
}
override fun onResume() {
super.onResume()
otpHandler.post(otpRunnable)
}
override fun onPause() {
super.onPause()
otpHandler.removeCallbacks(otpRunnable)
}
private fun updateOtpDisplay() {
val seed = binding.etOtpSeed.text.toString().trim()
if (seed.isEmpty()) {
binding.cardOtp.visibility = View.GONE
return
}
try {
val otp = Totp.generate(seed)
val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt()
val remaining = 30 - secondsInPeriod
binding.tvOtpCode.text = otp
binding.otpTimer.max = 30
binding.otpTimer.progress = remaining
binding.cardOtp.visibility = View.VISIBLE
} catch (e: Exception) {
binding.cardOtp.visibility = View.GONE
}
}
private fun attemptLogin() {
val username = binding.etUsername.text.toString().trim()
val password = binding.etPassword.text.toString()
val otpSeed = binding.etOtpSeed.text.toString().trim()
Log.d(TAG, "Login button pressed for username=$username")
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
Log.w(TAG, "Validation failed: empty fields")
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 prefs = requireContext().getSharedPreferences("mib_prefs", android.content.Context.MODE_PRIVATE)
val flow = MibLoginFlow(prefs)
viewLifecycleOwner.lifecycleScope.launch {
try {
Log.d(TAG, "Starting login flow on IO dispatcher")
val accounts = withContext(Dispatchers.IO) {
flow.login(username, password, otpSeed)
}
Log.d(TAG, "Login succeeded, got ${accounts.size} accounts")
(requireActivity().application as BasedBankApp).accounts = accounts
startActivity(Intent(requireContext(), HomeActivity::class.java))
requireActivity().finish()
} catch (e: Exception) {
Log.e(TAG, "Login failed: ${e.message}", e)
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
}
}
@@ -0,0 +1,18 @@
package sh.sar.basedbank.ui.login
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.fragment.NavHostFragment
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ActivityLoginBinding
class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
}
}
@@ -0,0 +1,51 @@
package sh.sar.basedbank.ui.onboarding
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayoutMediator
import sh.sar.basedbank.databinding.ActivityOnboardingBinding
import sh.sar.basedbank.ui.login.LoginActivity
class OnboardingActivity : AppCompatActivity() {
private lateinit var binding: ActivityOnboardingBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityOnboardingBinding.inflate(layoutInflater)
setContentView(binding.root)
val adapter = OnboardingPagerAdapter(this)
binding.viewPager.adapter = adapter
TabLayoutMediator(binding.dotsIndicator, binding.viewPager) { _, _ -> }.attach()
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
updateButtons(position, adapter.itemCount)
}
})
updateButtons(0, adapter.itemCount)
binding.btnNext.setOnClickListener {
val next = binding.viewPager.currentItem + 1
if (next < adapter.itemCount) binding.viewPager.currentItem = next
}
binding.btnGetStarted.setOnClickListener {
getSharedPreferences("prefs", MODE_PRIVATE)
.edit().putBoolean("onboarding_done", true).apply()
startActivity(Intent(this, LoginActivity::class.java))
finish()
}
}
private fun updateButtons(position: Int, count: Int) {
val isLast = position == count - 1
binding.btnNext.visibility = if (isLast) View.GONE else View.VISIBLE
binding.btnGetStarted.visibility = if (isLast) View.VISIBLE else View.GONE
}
}
@@ -0,0 +1,55 @@
package sh.sar.basedbank.ui.onboarding
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import sh.sar.basedbank.databinding.FragmentOnboardingSlideBinding
class OnboardingFragment : Fragment() {
private var _binding: FragmentOnboardingSlideBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentOnboardingSlideBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val title = requireArguments().getString(ARG_TITLE, "")
val desc = requireArguments().getString(ARG_DESC, "")
val icon = requireArguments().getInt(ARG_ICON, 0)
val isFirst = requireArguments().getBoolean(ARG_IS_FIRST, false)
binding.icon.setImageResource(icon)
binding.title.text = title
binding.description.text = desc
// On the first slide, show the two placeholder cards for upcoming banks
binding.placeholderCards.visibility = if (isFirst) View.VISIBLE else View.GONE
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
private const val ARG_TITLE = "title"
private const val ARG_DESC = "desc"
private const val ARG_ICON = "icon"
private const val ARG_IS_FIRST = "is_first"
fun newInstance(slide: OnboardingSlide) = OnboardingFragment().apply {
arguments = bundleOf(
ARG_TITLE to slide.title,
ARG_DESC to slide.description,
ARG_ICON to slide.iconRes,
ARG_IS_FIRST to slide.isFirst
)
}
}
}
@@ -0,0 +1,42 @@
package sh.sar.basedbank.ui.onboarding
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import sh.sar.basedbank.R
class OnboardingPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
private val slides = listOf(
OnboardingSlide(
title = activity.getString(R.string.onboarding_title_1),
description = activity.getString(R.string.onboarding_desc_1),
iconRes = R.drawable.ic_launcher_foreground,
isFirst = true
),
OnboardingSlide(
title = activity.getString(R.string.onboarding_title_2),
description = activity.getString(R.string.onboarding_desc_2),
iconRes = R.drawable.ic_launcher_foreground,
isFirst = false
),
OnboardingSlide(
title = activity.getString(R.string.onboarding_title_3),
description = activity.getString(R.string.onboarding_desc_3),
iconRes = R.drawable.ic_launcher_foreground,
isFirst = false
)
)
override fun getItemCount() = slides.size
override fun createFragment(position: Int): Fragment =
OnboardingFragment.newInstance(slides[position])
}
data class OnboardingSlide(
val title: String,
val description: String,
val iconRes: Int,
val isFirst: Boolean
)
@@ -1,42 +0,0 @@
package sh.sar.basedbank.ui.slideshow
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import sh.sar.basedbank.databinding.FragmentSlideshowBinding
class SlideshowFragment : Fragment() {
private var _binding: FragmentSlideshowBinding? = null
// This property is only valid between onCreateView and
// onDestroyView.
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val slideshowViewModel =
ViewModelProvider(this).get(SlideshowViewModel::class.java)
_binding = FragmentSlideshowBinding.inflate(inflater, container, false)
val root: View = binding.root
val textView: TextView = binding.textSlideshow
slideshowViewModel.text.observe(viewLifecycleOwner) {
textView.text = it
}
return root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
@@ -1,13 +0,0 @@
package sh.sar.basedbank.ui.slideshow
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class SlideshowViewModel : ViewModel() {
private val _text = MutableLiveData<String>().apply {
value = "This is slideshow Fragment"
}
val text: LiveData<String> = _text
}
@@ -0,0 +1,56 @@
package sh.sar.basedbank.util
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.experimental.and
object Totp {
/**
* Generate a 6-digit TOTP code from a Base32-encoded secret (RFC 6238 / RFC 4226).
* Uses HmacSHA1, 30-second window, 6 digits — matching standard authenticator apps.
*/
fun generate(base32Secret: String, digits: Int = 6, periodSeconds: Long = 30): String {
val key = base32Decode(base32Secret.uppercase().replace(" ", "").replace("-", ""))
val counter = System.currentTimeMillis() / 1000L / periodSeconds
val otp = hotp(key, counter, digits)
return otp.toString().padStart(digits, '0')
}
private fun hotp(key: ByteArray, counter: Long, digits: Int): Int {
val msg = ByteArray(8) { i -> ((counter shr ((7 - i) * 8)) and 0xFF).toByte() }
val mac = Mac.getInstance("HmacSHA1")
mac.init(SecretKeySpec(key, "HmacSHA1"))
val hash = mac.doFinal(msg)
val offset = (hash[hash.size - 1] and 0x0F).toInt()
val code = ((hash[offset].toInt() and 0x7F) shl 24) or
((hash[offset + 1].toInt() and 0xFF) shl 16) or
((hash[offset + 2].toInt() and 0xFF) shl 8) or
(hash[offset + 3].toInt() and 0xFF)
val modulus = Math.pow(10.0, digits.toDouble()).toInt()
return code % modulus
}
private val BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
private fun base32Decode(input: String): ByteArray {
val clean = input.trimEnd('=')
val output = ByteArray(clean.length * 5 / 8)
var buffer = 0
var bitsLeft = 0
var outIndex = 0
for (c in clean) {
val v = BASE32_CHARS.indexOf(c)
require(v >= 0) { "Invalid Base32 character: $c" }
buffer = (buffer shl 5) or v
bitsLeft += 5
if (bitsLeft >= 8) {
bitsLeft -= 8
output[outIndex++] = ((buffer shr bitsLeft) and 0xFF).toByte()
}
}
return output
}
}