diff --git a/.gitignore b/.gitignore
index aa724b7..3fb429c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,6 @@
.externalNativeBuild
.cxx
local.properties
+docs/mibapi/tmp
+docs/bmlapi/tmp
+tmp
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..59cfd4d
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 97f0a8e..639c779 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -1,5 +1,6 @@
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 74dd639..b2c751a 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,4 +1,3 @@
-
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 81a7fdf..24ce419 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -39,7 +39,6 @@ android {
}
dependencies {
-
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
@@ -48,7 +47,21 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.navigation.fragment.ktx)
implementation(libs.androidx.navigation.ui.ktx)
+
+ // ViewPager2 for onboarding
+ implementation("androidx.viewpager2:viewpager2:1.1.0")
+
+ // RecyclerView for accounts list
+ implementation("androidx.recyclerview:recyclerview:1.3.2")
+
+ // OkHttp for API calls
+ implementation("com.squareup.okhttp3:okhttp:4.11.0")
+ implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
+
+ // Coroutines
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 204f31a..6d00ca1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,7 +2,11 @@
+
+
+
+
+ android:label="@string/app_name">
-
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt
new file mode 100644
index 0000000..6f9478e
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt
@@ -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 = emptyList()
+ var fullName: String = ""
+
+ override fun onCreate() {
+ super.onCreate()
+ DynamicColors.applyToActivitiesIfAvailable(this)
+ }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/MainActivity.kt b/app/src/main/java/sh/sar/basedbank/MainActivity.kt
index 727975a..18ced06 100644
--- a/app/src/main/java/sh/sar/basedbank/MainActivity.kt
+++ b/app/src/main/java/sh/sar/basedbank/MainActivity.kt
@@ -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()
- }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibCrypto.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibCrypto.kt
new file mode 100644
index 0000000..2bc99cb
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibCrypto.kt
@@ -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))
+ }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt
new file mode 100644
index 0000000..d1b29a5
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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): List {
+ val allAccounts = mutableListOf()
+ 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 {
+ 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("")
+ }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt
new file mode 100644
index 0000000..137474f
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt
@@ -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
+)
diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibNonce.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibNonce.kt
new file mode 100644
index 0000000..9a7811a
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibNonce.kt
@@ -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()
+ val lastTwoList = mutableListOf()
+ val digitSumList = mutableListOf()
+ 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()
+ for ((i, group) in groups.withIndex()) {
+ val tokens = group.trim().split(" ")
+ var carry = lastTwoList[i]
+ val ds = digitSumList[i]
+ val nonceDigits = mutableListOf()
+
+ 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()
+}
diff --git a/app/src/main/java/sh/sar/basedbank/ui/gallery/GalleryFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/gallery/GalleryFragment.kt
deleted file mode 100644
index 46d8400..0000000
--- a/app/src/main/java/sh/sar/basedbank/ui/gallery/GalleryFragment.kt
+++ /dev/null
@@ -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
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/sh/sar/basedbank/ui/gallery/GalleryViewModel.kt b/app/src/main/java/sh/sar/basedbank/ui/gallery/GalleryViewModel.kt
deleted file mode 100644
index 5442a55..0000000
--- a/app/src/main/java/sh/sar/basedbank/ui/gallery/GalleryViewModel.kt
+++ /dev/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().apply {
- value = "This is gallery Fragment"
- }
- val text: LiveData = _text
-}
\ No newline at end of file
diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt
new file mode 100644
index 0000000..16013a3
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt
@@ -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) :
+ RecyclerView.Adapter() {
+
+ 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- = 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
+ }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt
new file mode 100644
index 0000000..1f1e4e7
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeFragment.kt
deleted file mode 100644
index ced869f..0000000
--- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeFragment.kt
+++ /dev/null
@@ -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
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt
deleted file mode 100644
index 432ee05..0000000
--- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt
+++ /dev/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().apply {
- value = "This is home Fragment"
- }
- val text: LiveData = _text
-}
\ No newline at end of file
diff --git a/app/src/main/java/sh/sar/basedbank/ui/login/BankSelectionFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/login/BankSelectionFragment.kt
new file mode 100644
index 0000000..4b19ab7
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/ui/login/BankSelectionFragment.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt
new file mode 100644
index 0000000..3ac24c8
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/ui/login/LoginActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/login/LoginActivity.kt
new file mode 100644
index 0000000..eda7912
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/ui/login/LoginActivity.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingActivity.kt
new file mode 100644
index 0000000..8db7c0a
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingActivity.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingFragment.kt
new file mode 100644
index 0000000..bb038e2
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingFragment.kt
@@ -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
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingPagerAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingPagerAdapter.kt
new file mode 100644
index 0000000..e6c5170
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingPagerAdapter.kt
@@ -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
+)
diff --git a/app/src/main/java/sh/sar/basedbank/ui/slideshow/SlideshowFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/slideshow/SlideshowFragment.kt
deleted file mode 100644
index 97e7821..0000000
--- a/app/src/main/java/sh/sar/basedbank/ui/slideshow/SlideshowFragment.kt
+++ /dev/null
@@ -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
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/sh/sar/basedbank/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/sh/sar/basedbank/ui/slideshow/SlideshowViewModel.kt
deleted file mode 100644
index 91d187f..0000000
--- a/app/src/main/java/sh/sar/basedbank/ui/slideshow/SlideshowViewModel.kt
+++ /dev/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().apply {
- value = "This is slideshow Fragment"
- }
- val text: LiveData = _text
-}
\ No newline at end of file
diff --git a/app/src/main/java/sh/sar/basedbank/util/Totp.kt b/app/src/main/java/sh/sar/basedbank/util/Totp.kt
new file mode 100644
index 0000000..beaa358
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/util/Totp.kt
@@ -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
+ }
+}
diff --git a/app/src/main/res/anim/slide_in_left.xml b/app/src/main/res/anim/slide_in_left.xml
new file mode 100644
index 0000000..13e4ed9
--- /dev/null
+++ b/app/src/main/res/anim/slide_in_left.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/main/res/anim/slide_in_right.xml b/app/src/main/res/anim/slide_in_right.xml
new file mode 100644
index 0000000..57e3664
--- /dev/null
+++ b/app/src/main/res/anim/slide_in_right.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/main/res/anim/slide_out_left.xml b/app/src/main/res/anim/slide_out_left.xml
new file mode 100644
index 0000000..5402a77
--- /dev/null
+++ b/app/src/main/res/anim/slide_out_left.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/main/res/anim/slide_out_right.xml b/app/src/main/res/anim/slide_out_right.xml
new file mode 100644
index 0000000..333416c
--- /dev/null
+++ b/app/src/main/res/anim/slide_out_right.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/bml_logo.png b/app/src/main/res/drawable/bml_logo.png
new file mode 100644
index 0000000..f6e6e5d
Binary files /dev/null and b/app/src/main/res/drawable/bml_logo.png differ
diff --git a/app/src/main/res/drawable/chip_background.xml b/app/src/main/res/drawable/chip_background.xml
new file mode 100644
index 0000000..efebb9e
--- /dev/null
+++ b/app/src/main/res/drawable/chip_background.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_menu_camera.xml b/app/src/main/res/drawable/ic_menu_camera.xml
deleted file mode 100644
index 634fe92..0000000
--- a/app/src/main/res/drawable/ic_menu_camera.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_menu_gallery.xml b/app/src/main/res/drawable/ic_menu_gallery.xml
deleted file mode 100644
index 03c7709..0000000
--- a/app/src/main/res/drawable/ic_menu_gallery.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/ic_menu_slideshow.xml b/app/src/main/res/drawable/ic_menu_slideshow.xml
deleted file mode 100644
index 5e9e163..0000000
--- a/app/src/main/res/drawable/ic_menu_slideshow.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/mib_faisanet_logo.xml b/app/src/main/res/drawable/mib_faisanet_logo.xml
new file mode 100644
index 0000000..73adada
--- /dev/null
+++ b/app/src/main/res/drawable/mib_faisanet_logo.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/mib_logo.png b/app/src/main/res/drawable/mib_logo.png
new file mode 100644
index 0000000..24b349b
Binary files /dev/null and b/app/src/main/res/drawable/mib_logo.png differ
diff --git a/app/src/main/res/drawable/side_nav_bar.xml b/app/src/main/res/drawable/side_nav_bar.xml
deleted file mode 100644
index 6d81870..0000000
--- a/app/src/main/res/drawable/side_nav_bar.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/drawable/tab_indicator_selector.xml b/app/src/main/res/drawable/tab_indicator_selector.xml
new file mode 100644
index 0000000..cd2b239
--- /dev/null
+++ b/app/src/main/res/drawable/tab_indicator_selector.xml
@@ -0,0 +1,21 @@
+
+
+
-
+
+
-
+
+
+
+
+
+
+ -
+
+
-
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml
new file mode 100644
index 0000000..0570341
--- /dev/null
+++ b/app/src/main/res/layout/activity_home.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml
new file mode 100644
index 0000000..6551d2d
--- /dev/null
+++ b/app/src/main/res/layout/activity_login.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
deleted file mode 100644
index 6c7dd7c..0000000
--- a/app/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml
new file mode 100644
index 0000000..3d1eb01
--- /dev/null
+++ b/app/src/main/res/layout/activity_onboarding.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/app_bar_main.xml b/app/src/main/res/layout/app_bar_main.xml
deleted file mode 100644
index 50fccd5..0000000
--- a/app/src/main/res/layout/app_bar_main.xml
+++ /dev/null
@@ -1,34 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/content_main.xml b/app/src/main/res/layout/content_main.xml
deleted file mode 100644
index 6e0ea39..0000000
--- a/app/src/main/res/layout/content_main.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_bank_selection.xml b/app/src/main/res/layout/fragment_bank_selection.xml
new file mode 100644
index 0000000..925e202
--- /dev/null
+++ b/app/src/main/res/layout/fragment_bank_selection.xml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_credentials.xml b/app/src/main/res/layout/fragment_credentials.xml
new file mode 100644
index 0000000..9a8cc98
--- /dev/null
+++ b/app/src/main/res/layout/fragment_credentials.xml
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_gallery.xml b/app/src/main/res/layout/fragment_gallery.xml
deleted file mode 100644
index 643fe25..0000000
--- a/app/src/main/res/layout/fragment_gallery.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml
deleted file mode 100644
index f3d9b08..0000000
--- a/app/src/main/res/layout/fragment_home.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_onboarding_slide.xml b/app/src/main/res/layout/fragment_onboarding_slide.xml
new file mode 100644
index 0000000..c4faf04
--- /dev/null
+++ b/app/src/main/res/layout/fragment_onboarding_slide.xml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_slideshow.xml b/app/src/main/res/layout/fragment_slideshow.xml
deleted file mode 100644
index 2141a33..0000000
--- a/app/src/main/res/layout/fragment_slideshow.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_account.xml b/app/src/main/res/layout/item_account.xml
new file mode 100644
index 0000000..0d29a2e
--- /dev/null
+++ b/app/src/main/res/layout/item_account.xml
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_profile_header.xml b/app/src/main/res/layout/item_profile_header.xml
new file mode 100644
index 0000000..c7b8285
--- /dev/null
+++ b/app/src/main/res/layout/item_profile_header.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/nav_header_main.xml b/app/src/main/res/layout/nav_header_main.xml
deleted file mode 100644
index c145545..0000000
--- a/app/src/main/res/layout/nav_header_main.xml
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/menu/activity_main_drawer.xml b/app/src/main/res/menu/activity_main_drawer.xml
deleted file mode 100644
index d7f2df2..0000000
--- a/app/src/main/res/menu/activity_main_drawer.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
\ No newline at end of file
diff --git a/app/src/main/res/menu/main.xml b/app/src/main/res/menu/main.xml
deleted file mode 100644
index 412d5f8..0000000
--- a/app/src/main/res/menu/main.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
\ No newline at end of file
diff --git a/app/src/main/res/navigation/login_nav.xml b/app/src/main/res/navigation/login_nav.xml
new file mode 100644
index 0000000..ff26b36
--- /dev/null
+++ b/app/src/main/res/navigation/login_nav.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml
deleted file mode 100644
index ad2d55c..0000000
--- a/app/src/main/res/navigation/mobile_navigation.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index 7c4b523..38f791a 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -1,16 +1,8 @@
-
-
-
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index f8c6127..bccb7d3 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,10 +1,6 @@
- #FFBB86FC
- #FF6200EE
- #FF3700B3
- #FF03DAC5
- #FF018786
- #FF000000
- #FFFFFFFF
-
\ No newline at end of file
+
+ #3F65AD
+ #9AD141
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 89c3f2e..02459f3 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,13 +1,31 @@
- Based Bank
- Open navigation drawer
- Close navigation drawer
- Android Studio
- android.studio@android.com
- Navigation header
- Settings
+ BasedBank
- Home
- Gallery
- Slideshow
-
\ No newline at end of file
+
+ Your Banks, One App
+ BasedBank brings all your Maldivian bank accounts together in one place. Check balances, view accounts, and more — without switching between apps.
+ More Banks Coming
+ Support for additional banks is on the way. Stay tuned as we expand coverage across the Maldives.
+ Get Started
+ Add your bank credentials and start viewing your accounts. Your data stays on your device.
+ Coming Soon
+ Next
+ Get Started
+
+
+ Select Your Bank
+ Choose the bank you want to sign in to.
+ Maldives Islamic Bank
+ Faisanet Mobile Banking
+ Sign In
+ Enter your Maldives Islamic Bank credentials.
+ Username
+ Password
+ OTP Seed (TOTP Secret)
+ The Base32 secret from your authenticator setup
+ Login
+
+
+ Accounts
+ Available Balance
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 3cdf23e..e43902e 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,25 +1,7 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..ebc833f
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,6 @@
+
+
+
+ faisanet.mib.com.mv
+
+
diff --git a/bml-logo.svg b/bml-logo.svg
new file mode 100644
index 0000000..4399f6f
--- /dev/null
+++ b/bml-logo.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/docs/mibapi/ACCOUNTS.md b/docs/mibapi/ACCOUNTS.md
new file mode 100644
index 0000000..4827b64
--- /dev/null
+++ b/docs/mibapi/ACCOUNTS.md
@@ -0,0 +1,172 @@
+# MIB Faisanet — List Accounts & Balances
+
+Account numbers and balances are returned by the **Select Profile** call (`routePath: P47`).
+The login initialization call (`A41`) returns an empty `accountBalance` array until a profile is selected.
+
+---
+
+## Flow to Get Account Balances
+
+```
+[0] sfunc=i (key1) → DH key exchange → derive session_key
+[1] sfunc=n A44 → get userSalt
+[2] sfunc=n A41 → login with password → returns operatingProfiles (no balances yet)
+[3] sfunc=n A42 → OTP verify
+[4] sfunc=n P47 → select profile → returns accountBalance array
+```
+
+Steps 0–3 are the standard login flow (see `LOGIN_FLOW.md`). Step 4 is the new call.
+
+---
+
+## Step 1 — Get Profile List from A41 Response
+
+The `A41` login initialization response includes `operatingProfiles` — the list of
+profiles available to the user (personal, business, etc.).
+
+**Relevant fields from A41 response:**
+
+```json
+{
+ "defaultProfile": "2",
+ "operatingProfiles": [
+ {
+ "profileId": "",
+ "customerProfileId": "",
+ "annexId": "",
+ "customerId": "",
+ "name": "",
+ "cifType": "Individual",
+ "customerImage": "",
+ "profileType": "0",
+ "color": ""
+ },
+ {
+ "profileId": "",
+ "customerProfileId": "",
+ "annexId": "",
+ "customerId": "",
+ "name": "",
+ "cifType": "Sole Propr",
+ "customerImage": "",
+ "profileType": "1",
+ "color": ""
+ }
+ ],
+ "selectedProfileId": null,
+ "selectedProfileType": null,
+ "profileSelected": false
+}
+```
+
+`profileType` values observed:
+| Value | Meaning |
+|---|---|
+| `"0"` | Individual (personal) |
+| `"1"` | Sole Proprietor (business) |
+
+---
+
+## Step 2 — Select Profile (`sfunc=n`, `routePath: P47`)
+
+**Key**: session key (derived from `sfunc=i` response)
+
+**Request:**
+```json
+{
+ "sfunc": "n",
+ "xxid": "",
+ "data": {
+ "profileType": "",
+ "profileId": "",
+ "nonce": "",
+ "appId": "",
+ "sodium": "",
+ "routePath": "P47",
+ "xxid": ""
+ }
+}
+```
+
+**Response:**
+```json
+{
+ "success": true,
+ "reasonCode": "101",
+ "reasonText": "Profile Selected!",
+ "landingPage": "0",
+ "accountBalance": [ ... ],
+ "accessRights": { ... },
+ "services": []
+}
+```
+
+---
+
+## accountBalance Array
+
+Each element in `accountBalance` represents one account:
+
+```json
+{
+ "cif": "",
+ "accountNumber": "",
+ "accountBriefName": "",
+ "template": "",
+ "currencyCode": "",
+ "currencyName": "",
+ "accountTypeName": "",
+ "transfer": "Y",
+ "branchName": "",
+ "availableBalance": "",
+ "currentBalance": "",
+ "blockedAmount": "",
+ "settlementBalance": "",
+ "mvrBalance": "",
+ "statusDesc": "Active"
+}
+```
+
+### Field reference
+
+| Field | Description |
+|---|---|
+| `accountNumber` | Full account number |
+| `accountBriefName` | Human-readable account label |
+| `currencyCode` | ISO 4217 numeric (e.g. `"462"` = MVR, `"840"` = USD) |
+| `currencyName` | ISO 4217 alpha (e.g. `"MVR"`, `"USD"`) |
+| `accountTypeName` | Account type (e.g. `"Saving Account"`) |
+| `availableBalance` | Spendable balance |
+| `currentBalance` | Ledger balance |
+| `blockedAmount` | Held/blocked funds (negative means funds are held) |
+| `settlementBalance` | Balance including pending settlements |
+| `mvrBalance` | All balances converted to MVR for display |
+| `transfer` | `"Y"` if account can be used as transfer source |
+| `statusDesc` | Account status (e.g. `"Active"`) |
+| `cif` | Customer Information File number |
+| `template` | UI template ID (controls how card is rendered in-app) |
+
+---
+
+## accessRights
+
+Also returned in the P47 response, describes what the selected profile can do:
+
+```json
+{
+ "numAccounts": "",
+ "packageRights": "[1,2,3,4,6,7,8,9,10,11,12,...]",
+ "roleRights": "[]"
+}
+```
+
+`packageRights` is a JSON array encoded as a string — parse it separately.
+
+---
+
+## Notes
+
+- `accountBalance` in the `A41` response is always `[]`. Balances are only returned after `P47`.
+- To switch between profiles (personal ↔ business), call `P47` again with the other profile's `profileId` and `profileType`.
+- `mvrBalance` is always in MVR regardless of the account's native currency, useful for showing a unified total.
+- All balance fields are **decimal strings**, not numbers — parse with `Decimal` for precision.
diff --git a/docs/mibapi/API.md b/docs/mibapi/API.md
new file mode 100644
index 0000000..ca8cfcf
--- /dev/null
+++ b/docs/mibapi/API.md
@@ -0,0 +1,345 @@
+# Faisanet MIB API Documentation
+
+Reverse-engineered from `mv.com.mib.faisamobilex` (React Native, Hermes bytecode v96).
+
+---
+
+## Base
+
+- **URL**: `https://faisanet.mib.com.mv/faisamobilex_smvc/`
+- **Method**: `POST /`
+- **Content-Type**: `application/x-www-form-urlencoded; charset=utf-8`
+- **User-Agent**: `android/1.0`
+- **Accept**: `application/json`
+
+All requests share the same form body structure:
+```
+sfunc=&data=
+```
+
+---
+
+## Encryption
+
+### Algorithm
+- **Cipher**: Blowfish, ECB mode, PKCS5 padding
+- **Input**: raw UTF-8 bytes of JSON payload string
+- **Key**: raw UTF-8 bytes of key string
+- **Output**: base64-encoded ciphertext (URL-encoded when sent as form data)
+
+### Python equivalent
+```python
+from Crypto.Cipher import Blowfish
+from Crypto.Util.Padding import pad, unpad
+import base64
+
+def encrypt(payload: dict, key: str) -> str:
+ import json
+ plaintext = json.dumps(payload).encode()
+ key_bytes = key.encode('latin-1')
+ cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
+ ct = cipher.encrypt(pad(plaintext, Blowfish.block_size))
+ return base64.b64encode(ct).decode()
+
+def decrypt(ciphertext_b64: str, key: str) -> dict:
+ import json
+ key_bytes = key.encode('latin-1')
+ ct = base64.b64decode(ciphertext_b64)
+ cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
+ plaintext = unpad(cipher.decrypt(ct), Blowfish.block_size)
+ return json.loads(plaintext.decode())
+```
+
+### Key lifecycle
+
+| Phase | Key used |
+|---|---|
+| `sfunc=r` (key exchange) | `DEFAULT_KEY` (hardcoded in app) |
+| All subsequent requests | DH-derived session key |
+
+**DEFAULT_KEY** (hardcoded):
+```
+8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678
+```
+
+---
+
+## Diffie-Hellman Key Exchange
+
+The app uses a **custom Diffie-Hellman** scheme to derive a session key.
+
+### Fixed parameters (hardcoded in app)
+
+> Note: the variable names in the app's source are swapped from their DH role.
+> `A_VALUE` in the source is the **exponent** (shorter number), `P_VALUE` is the **prime modulus** (longer number).
+
+```
+G (generator) = 2
+A (client privkey) = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
+P (prime modulus) = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
+```
+
+> **Note**: `A` (client private key) is hardcoded in the app — this DH provides no real security.
+
+### Session key derivation
+```python
+import hashlib, base64
+
+def derive_session_key(smod: int) -> str:
+ A = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
+ P = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
+ shared_secret = pow(smod, A, P)
+ sha256_hex = hashlib.sha256(str(shared_secret).encode()).hexdigest().upper()
+ return base64.b64encode(bytes.fromhex(sha256_hex)).decode()
+```
+
+The resulting session key is always a **44-character base64 string** (32 bytes / 256-bit SHA-256 output), for example:
+```
+XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=
+```
+It changes every session because `smod` is different each time.
+
+---
+
+## Endpoints (sfunc values)
+
+### `r` — Key Exchange (initiate session)
+
+**Request payload** (encrypted with DEFAULT_KEY):
+```json
+{
+ "sfunc": "r",
+ "data": {
+ "cmod": "",
+ "appId": "IOS17.2-",
+ "routePath": "S40",
+ "sodium": "",
+ "xxid": ""
+ }
+}
+```
+
+**Response payload** (encrypted with DEFAULT_KEY):
+```json
+{
+ "success": true,
+ "responseCode": "1",
+ "reasonCode": "201",
+ "reasonText": "Key generated successfully.",
+ "smod": "",
+ "nonceGenerator": "",
+ "xxid": "",
+ "sodium": "",
+ "encMethod": 1
+}
+```
+
+After this call:
+- Compute `encryptionKey = derive_session_key(int(smod))`
+- Store `xxid` and `nonceGenerator` for subsequent calls
+
+---
+
+## Request envelope structure
+
+All requests after key exchange use this structure:
+```json
+{
+ "sfunc": "",
+ "xxid": "",
+ "data": {
+ "nonce": "",
+ "appId": "",
+ "sodium": "",
+ "routePath": "",
+ "xxid": "",
+ ...additional fields...
+ }
+}
+```
+
+Encrypted with the DH-derived `encryptionKey`.
+
+---
+
+## Login Flows
+
+### First-time device registration (no stored key1/key2)
+
+1. `sfunc=r` → `S40` — DH key exchange with `DEFAULT_KEY` → receive `xxid`, `nonceGenerator`, `smod` → derive session key
+2. `sfunc=n` → `A44` — get `userSalt` for username
+3. `sfunc=n` → `C41` — submit `pgf03` (computed from password + userSalt + random clientSalt)
+4. `sfunc=n` → `C42` — verify TOTP OTP → receive `key1` and `key2`; persist them
+5. Continue with regular login below (using the just-received key1/key2)
+
+### Regular login (stored key1/key2 present)
+
+1. `sfunc=i` → `S40` — DH key exchange with `key1`, sending `key2` as extra form field → derive session key
+2. `sfunc=n` → `A44` — get `userSalt` for username
+3. `sfunc=n` → `A41` — submit `pgf03` → receive `operatingProfiles` list
+4. For each profile: `sfunc=n` → `P47` — fetch `accountBalance` array
+
+> **No A42 step in regular login.** OTP is only verified once during first-time registration (C42).
+
+### pgf03 formula
+
+```python
+h1 = SHA256(password).hexdigest().upper()
+h2 = SHA256(h1 + userSalt).hexdigest().upper()
+pgf03 = SHA256(clientSalt + h2).hexdigest().upper()
+```
+
+`clientSalt` is a random 32-character alphanumeric string generated fresh each login.
+
+---
+
+## Known route paths
+
+| sfunc | routePath | Description |
+|---|---|---|
+| `r` | `S40` | DH key exchange (first-time registration) |
+| `i` | `S40` | DH key exchange (regular login, sends `key1`/`key2`) |
+| `n` | `A44` | Get auth type — returns `userSalt` for the given `uname` |
+| `n` | `C41` | Registration: submit credentials (`uname`, `pgf03`, `clientSalt`) |
+| `n` | `C42` | Registration: verify OTP (`otp`, `uname`, `otpType=3`) — returns `key1`/`key2` |
+| `n` | `A41` | Login: submit credentials (`uname`, `pgf03`, `clientSalt`, `pmodTime`, `requireBankData`) — returns `operatingProfiles` |
+| `n` | `P47` | Fetch account balances for a profile (`profileType`, `profileId`) — returns `accountBalance` array |
+| `n` | `P40` | Update profile image |
+| `n` | `P42` | Delete profile image |
+
+> Note: `A42` (login OTP verify) is **not sent** during regular login. It was present in an older flow but is no longer used. `C42` is only sent during first-time device registration.
+
+---
+
+## Nonce Computation
+
+Every request after key exchange includes a `nonce` field computed from the `nonceGenerator`
+string returned by the key exchange response.
+
+### nonceGenerator format
+
+A string of 4 groups separated by `-`. Each group contains 8 space-separated tokens.
+Each token is a letter followed by a number (e.g. `M85`, `A37`, `C95`, `X2`).
+
+```
+M85 A87 A82 M82 M60 M31 A46 C95-M14 X83 A37 X2 C4 X22 X46 C95-M57 X29 C51 C34 S91 X60 S1 A15-M54 A89 S13 S18 C81 A70 X92 X59
+```
+
+### Nonce output format
+
+4 groups separated by `-`. Each group: a zero-padded 5-digit number followed by 7 two-digit
+numbers separated by spaces.
+
+```
+08160 19 73 45 17 89 07 10-00924 64 73 18 08 48 80 67-01026 20 17 13 26 26 43 24-00648 12 32 17 69 14 63 92
+```
+
+### Algorithm
+
+**Phase 1 — process first token of each group (produces seed values):**
+
+For each of the 4 groups (index `i`):
+1. Take `token[0]` (e.g. `M85`). Extract the number: `N = parseInt(token.replace(/\D/g, ''))`.
+2. Generate a random integer: `r = floor(random() * 99) + 1` (range 1–99 inclusive).
+3. Compute `product = N * r`. Zero-pad to 5 digits: `padded = product.toString().padStart(5, '0')`.
+4. Compute `digitSum[i]` = sum of all digits in `padded`.
+5. Store `lastTwo[i]` = `parseInt(padded.slice(-2))` (last two digits as integer).
+6. Accumulate `cumSum += digitSum[i]`.
+
+After all 4 groups: `cumSum` = sum of all four `digitSum` values.
+
+**Phase 2 — process tokens 1–7 of each group (produces nonce digits):**
+
+For each group (index `i`), process `token[1]` through `token[7]`:
+- Initialise `carry = lastTwo[i]`.
+- For each token at position `j` (1–7):
+ - Extract letter `op` and number `num`.
+ - Compute `val` based on `op`:
+ | op | formula |
+ |---|---|
+ | `M` | `(carry % num) + digitSum[i] + cumSum` |
+ | `A` | `carry + num + digitSum[i] + cumSum` |
+ | `S` | `(carry * carry) + num + digitSum[i] + cumSum` |
+ | `X` | `(carry * num) + digitSum[i] + cumSum` |
+ | `C` | `(carry * carry * carry) + num + digitSum[i] + cumSum` |
+ - Nonce digit = `parseInt(val.toString().slice(-2))` (last two digits as integer).
+ - Update `carry = nonceDigit` for the next token.
+
+**Assembling the nonce string:**
+
+For each group `i`:
+```
+group_str = padded[i] + " " + nonceDigit[i][0].toString().padStart(2,'0') + " " + ... (7 digits)
+```
+Join 4 groups with `-`.
+
+### Python implementation
+
+```python
+import math, random
+
+def generate_nonce(nonce_generator: str) -> str:
+ groups = nonce_generator.split('-')
+
+ padded_list, last_two, digit_sum = [], [], []
+ cum_sum = 0
+
+ # Phase 1
+ for group in groups:
+ tokens = group.split(' ')
+ n = int(''.join(c for c in tokens[0] if c.isdigit()))
+ r = math.floor(random.random() * 99) + 1
+ product = n * r
+ padded = str(product).zfill(5)
+ ds = sum(int(d) for d in padded)
+ lt = int(padded[-2:])
+ padded_list.append(padded)
+ last_two.append(lt)
+ digit_sum.append(ds)
+ cum_sum += ds
+
+ # Phase 2
+ result_groups = []
+ for i, group in enumerate(groups):
+ tokens = group.split(' ')
+ carry = last_two[i]
+ ds = digit_sum[i]
+ nonce_digits = []
+ for token in tokens[1:]:
+ op = ''.join(c for c in token if c.isalpha())
+ num = int(''.join(c for c in token if c.isdigit()))
+ if op == 'M':
+ val = (carry % num) + ds + cum_sum
+ elif op == 'A':
+ val = carry + num + ds + cum_sum
+ elif op == 'S':
+ val = (carry * carry) + num + ds + cum_sum
+ elif op == 'X':
+ val = (carry * num) + ds + cum_sum
+ elif op == 'C':
+ val = (carry * carry * carry) + num + ds + cum_sum
+ else:
+ val = 0
+ digit = int(str(val)[-2:])
+ nonce_digits.append(digit)
+ carry = digit
+ group_str = padded_list[i] + ' ' + ' '.join(str(d).zfill(2) for d in nonce_digits)
+ result_groups.append(group_str)
+
+ return '-'.join(result_groups)
+```
+
+### Notes
+
+- `nonce` and `sodium` are **separate** request fields. `sodium` is an independent random integer
+ (observed range ~1M–16M, approximately 23–24 bit).
+- The nonce string is the same value for both the `nonce` and ... actually they are different fields:
+ `nonce` = the computed nonce string; `sodium` = a random integer sent as a plain string.
+- For `sfunc=i`, `key2` is sent as a **separate form field** (not inside the encrypted payload):
+ `key2=&sfunc=i&data=`. The encrypted payload is the inner data object only,
+ encrypted with `key1`.
+- For all `sfunc=n` requests (every request after key exchange), `xxid` is sent as a **separate
+ unencrypted form field** as the FIRST field:
+ `xxid=&sfunc=n&data=`. The `xxid` also appears inside the encrypted
+ payload. Field order matters — `xxid` must come before `sfunc` and `data`.
+
diff --git a/docs/mibapi/ENCRYPTION.md b/docs/mibapi/ENCRYPTION.md
new file mode 100644
index 0000000..bbb59c2
--- /dev/null
+++ b/docs/mibapi/ENCRYPTION.md
@@ -0,0 +1,180 @@
+# MIB Faisanet API — Encryption & Decryption
+
+## Overview
+
+All API traffic is encrypted using **Blowfish** in ECB mode with PKCS5 padding.
+Every request and response body is a single base64-encoded Blowfish ciphertext.
+
+There are two keys in play:
+
+| Key | Used for |
+|---|---|
+| `DEFAULT_KEY` (hardcoded) | The initial key exchange request and response (`sfunc=r`) |
+| Session key (DH-derived) | Every request and response after the key exchange |
+
+---
+
+## The DEFAULT_KEY
+
+```
+8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678
+```
+
+This key is hardcoded in the app's JavaScript bundle. It is only used for the
+first call (`sfunc=r`) which establishes a session key via Diffie-Hellman.
+
+---
+
+## Session Key Derivation (Diffie-Hellman)
+
+The app uses a custom DH key exchange to derive a per-session Blowfish key.
+All three DH parameters are hardcoded in the app:
+
+```
+G = 2
+P = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
+A = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
+```
+
+`A` is the client's private key. Because it is hardcoded and never rotates,
+anyone with the APK can derive the session key from a captured `smod`.
+
+### Step-by-step
+
+1. Client computes `cmod = G^A mod P` and sends it in the `sfunc=r` request.
+2. Server computes its own keypair and responds with `smod` (its public key).
+3. Client computes the shared secret: `shared = smod^A mod P`
+4. Client SHA-256 hashes the decimal string of the shared secret (uppercased hex).
+5. Client converts that hex string to raw bytes, then base64-encodes it.
+6. The result is the Blowfish key for the rest of the session.
+
+```python
+import hashlib, base64
+
+def derive_session_key(smod: int) -> str:
+ # A_VALUE in app = exponent (shorter), P_VALUE in app = modulus (longer)
+ A = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
+ P = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
+ shared = pow(smod, A, P)
+ sha256_hex = hashlib.sha256(str(shared).encode()).hexdigest().upper()
+ return base64.b64encode(bytes.fromhex(sha256_hex)).decode()
+```
+
+---
+
+## Encrypting a Request
+
+All request payloads follow this JSON structure before encryption:
+
+```json
+{
+ "sfunc": "",
+ "xxid": "",
+ "data": {
+ ...
+ }
+}
+```
+
+### Encryption steps
+
+1. `JSON.stringify` the payload.
+2. Use the raw UTF-8 bytes of the payload as plaintext.
+3. Use the raw UTF-8 bytes of the key string as the Blowfish key.
+4. Encrypt: Blowfish / ECB / PKCS5 padding.
+5. Base64-encode the ciphertext.
+6. URL-encode the base64 string.
+7. Send as form field: `sfunc=&data=`
+
+```python
+import json, base64
+from urllib.parse import quote
+from Crypto.Cipher import Blowfish
+from Crypto.Util.Padding import pad
+
+def encrypt(payload: dict, key: str) -> str:
+ plaintext = json.dumps(payload, separators=(',', ':')).encode('utf-8')
+ key_bytes = key.encode('latin-1')
+ cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
+ ct = cipher.encrypt(pad(plaintext, Blowfish.block_size))
+ return base64.b64encode(ct).decode()
+
+def build_request_body(payload: dict, key: str) -> str:
+ sfunc = payload.get('sfunc', '')
+ encrypted = encrypt(payload, key)
+ return f"sfunc={sfunc}&data={quote(encrypted)}"
+```
+
+---
+
+## Decrypting a Response
+
+The response body is a raw base64-encoded Blowfish ciphertext (no form encoding).
+
+### Decryption steps
+
+1. Base64-decode the response body to get the ciphertext bytes.
+2. Decrypt with Blowfish / ECB / PKCS5 padding using the appropriate key.
+3. Parse the result as JSON.
+
+```python
+import json, base64
+from Crypto.Cipher import Blowfish
+from Crypto.Util.Padding import unpad
+
+def decrypt(ciphertext_b64: str, key: str) -> dict:
+ key_bytes = key.encode('latin-1')
+ ct = base64.b64decode(ciphertext_b64)
+ cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
+ plaintext = unpad(cipher.decrypt(ct), Blowfish.block_size)
+ return json.loads(plaintext.decode('utf-8'))
+```
+
+---
+
+## Full Session Example
+
+### 1. Send key exchange (`sfunc=r`) — use DEFAULT_KEY
+
+Request form body:
+```
+sfunc=r&data=
+```
+
+Response (decrypted with DEFAULT_KEY):
+```json
+{
+ "success": true,
+ "smod": "",
+ "nonceGenerator": "",
+ "xxid": "",
+ "sodium": "",
+ "encMethod": 2
+}
+```
+
+### 2. Derive the session key from `smod`
+
+```python
+session_key = derive_session_key(int(response['smod']))
+# → a 44-character base64 string, e.g. "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX="
+# The key is 32 bytes (256-bit SHA-256 output) encoded as base64.
+```
+
+### 3. All subsequent requests — use session key
+
+Encrypt with `session_key`, decrypt responses with `session_key`.
+
+---
+
+## Quick reference
+
+| What | How |
+|---|---|
+| Cipher | Blowfish, ECB mode, PKCS5 padding |
+| Key for `sfunc=r` | `8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678` |
+| Key for everything else | `derive_session_key(smod)` |
+| Request encoding | JSON → encrypt → base64 → URL-encode → form field `data=` |
+| Response encoding | base64 → decrypt → JSON |
+| Key input | raw UTF-8 bytes of key string |
+| Plaintext input | raw UTF-8 bytes of JSON string |
diff --git a/docs/mibapi/LOGIN_FLOW.md b/docs/mibapi/LOGIN_FLOW.md
new file mode 100644
index 0000000..e17e678
--- /dev/null
+++ b/docs/mibapi/LOGIN_FLOW.md
@@ -0,0 +1,449 @@
+# MIB Faisanet — Login Flow
+
+Fully reverse engineered from a captured HAR trace of a first-time device
+registration followed immediately by a regular login.
+
+---
+
+## Key Corrections
+
+The DH parameter names in the app's source are **misleading**:
+
+| App variable | DH role | Value |
+|---|---|---|
+| `A_VALUE` | Exponent / client private key | `15635168026...` (shorter) |
+| `P_VALUE` | Prime modulus | `24103124269...` (longer) |
+| `G_VALUE` | Generator | `2` |
+
+The session key derivation is:
+```python
+shared = pow(smod, A_VALUE, P_VALUE) # NOT pow(smod, P_VALUE, A_VALUE)
+sha256_hex = SHA256(str(shared)).upper()
+session_key = base64(bytes.fromhex(sha256_hex))
+```
+
+---
+
+## Overview
+
+The full login sequence consists of two phases:
+
+| Phase | Purpose | Key used |
+|---|---|---|
+| **Phase 1** — Device registration | First time this device+account pair is seen | DH session key from `sfunc=r` |
+| **Phase 2** — Regular login | Every subsequent login | key1/key2 (from phase 1) → second DH → new session key |
+
+---
+
+## Full Flow Diagram
+
+```
+Client Server
+ | |
+ | [0] sfunc=r (DEFAULT_KEY) |
+ | { cmod, appId, routePath:S40, ... } |
+ |--------------------------------------------->|
+ | { smod, nonceGenerator, xxid, ... } |
+ |<---------------------------------------------|
+ | derive session_key_1 = DH(smod) |
+ | |
+ | [1] sfunc=n routePath:A44 (session_key_1)|
+ | { uname } |
+ |--------------------------------------------->|
+ | { loginType, userSalt } |
+ |<---------------------------------------------|
+ | |
+ | [2] sfunc=n routePath:C41 (session_key_1)| ← device registration init
+ | { uname, pgf03, clientSalt } |
+ |--------------------------------------------->|
+ | { key1, key2, otpTypes, fullName, ... } |
+ |<---------------------------------------------|
+ | |
+ | [3] sfunc=n routePath:C42 (session_key_1)| ← OTP verify (registration)
+ | { otp, uname, otpType } |
+ |--------------------------------------------->|
+ | { key1, key2, encryptionMethod:2, ... } |
+ |<---------------------------------------------|
+ | store key1, key2 on device |
+ | |
+ | [4] sfunc=i (key1) | ← second DH key exchange
+ | { cmod, appId, routePath:S40, key2 } |
+ |--------------------------------------------->|
+ | { smod, nonceGenerator, xxid, ... } |
+ |<---------------------------------------------|
+ | derive session_key_2 = DH(smod) |
+ | |
+ | [5] sfunc=n routePath:A44 (session_key_2)|
+ | { uname } |
+ |--------------------------------------------->|
+ | { loginType, userSalt } |
+ |<---------------------------------------------|
+ | |
+ | [6] sfunc=n routePath:A41 (session_key_2)| ← regular login init
+ | { uname, pgf03, clientSalt, requireBankData:1 }|
+ |--------------------------------------------->|
+ | { primaryOTPType, otpTypes, email, uuid, uuid2, ... }|
+ |<---------------------------------------------|
+ | |
+ | [7] sfunc=n routePath:P41 (session_key_2)| ← fetch profile image
+ | { imageHash } |
+ |--------------------------------------------->|
+ | { profileImage (base64 JPEG) } |
+ |<---------------------------------------------|
+ | |
+ | [8] sfunc=n routePath:A42 (session_key_2)| ← OTP verify (regular login)
+ | { otp, uname, otpType } |
+ |--------------------------------------------->|
+ | { ... session established ... } |
+ |<---------------------------------------------|
+```
+
+---
+
+## Step-by-Step Reference
+
+### [0] Initial Key Exchange — `sfunc=r`
+
+**Key**: `DEFAULT_KEY = 8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678`
+
+**Request body** (inner `data` field):
+```json
+{
+ "cmod": "",
+ "appId": "IOS17.2-<15 random chars>",
+ "routePath": "S40",
+ "sodium": "",
+ "xxid": ""
+}
+```
+
+**Full request** (outer wrapper, encrypted together):
+```json
+{ "sfunc": "r", "data": { ...above... } }
+```
+
+**Response** (decrypted with DEFAULT_KEY):
+```json
+{
+ "success": true,
+ "reasonCode": "201",
+ "reasonText": "Key generated successfully.",
+ "smod": "",
+ "nonceGenerator": "",
+ "xxid": "",
+ "sodium": "",
+ "encMethod": 2
+}
+```
+
+After this step:
+- Derive `session_key_1 = derive_session_key(smod)`
+- Save `xxid` and `nonceGenerator`
+
+---
+
+### [1] Get Auth Type — `sfunc=n`, `routePath: A44`
+
+**Key**: `session_key_1`
+
+**Request** (encrypted):
+```json
+{
+ "sfunc": "n",
+ "xxid": "",
+ "data": {
+ "uname": "",
+ "nonce": "",
+ "appId": "",
+ "sodium": "",
+ "routePath": "A44",
+ "xxid": ""
+ }
+}
+```
+
+**Response**:
+```json
+{
+ "success": true,
+ "reasonCode": "108",
+ "reasonText": "Auth type retrieved!",
+ "data": [
+ {
+ "loginType": "1",
+ "userSalt": ""
+ }
+ ]
+}
+```
+
+---
+
+### [2] Device Registration Init — `sfunc=n`, `routePath: C41`
+
+First-time only. Registers this device+account pair.
+
+**Key**: `session_key_1`
+
+**Request**:
+```json
+{
+ "sfunc": "n",
+ "xxid": "",
+ "data": {
+ "uname": "",
+ "pgf03": "",
+ "clientSalt": "",
+ "nonce": "",
+ "appId": "",
+ "sodium": "",
+ "routePath": "C41",
+ "xxid": ""
+ }
+}
+```
+
+**Response**:
+```json
+{
+ "success": true,
+ "reasonCode": "201",
+ "reasonText": "Registration Initialization Successfully.",
+ "primaryOTPType": "3",
+ "roleName": "Consumer Premium",
+ "otpTypes": [2, 3],
+ "fullName": "",
+ "lastLoginTime": "",
+ "customerImgHash": ""
+}
+```
+
+---
+
+### [3] OTP Verification (Registration) — `sfunc=n`, `routePath: C42`
+
+**Key**: `session_key_1`
+
+**Request**:
+```json
+{
+ "sfunc": "n",
+ "xxid": "",
+ "data": {
+ "otp": "<6-digit OTP>",
+ "uname": "",
+ "otpType": "3",
+ "nonce": "",
+ "appId": "",
+ "sodium": "",
+ "routePath": "C42",
+ "xxid": ""
+ }
+}
+```
+
+**Response**:
+```json
+{
+ "success": true,
+ "reasonCode": "101",
+ "reasonText": "registration success",
+ "data": [
+ {
+ "appId": "",
+ "createdDate": "",
+ "key1": "",
+ "key2": "",
+ "encryptionMethod": "2",
+ "appAgent": "android/1.0"
+ }
+ ]
+}
+```
+
+`key1` and `key2` are long-lived device credentials. `key1` is the Blowfish key
+for the next `sfunc=i` call. `key2` is sent as plaintext in the outer wrapper of
+that call.
+
+---
+
+### [4] Authenticated Key Exchange — `sfunc=i`
+
+Second DH exchange, authenticated with the device credentials.
+
+**Key**: `key1`
+
+**Request** (outer wrapper includes `key2`):
+```json
+{
+ "sfunc": "i",
+ "key2": "",
+ "data": {
+ "cmod": "",
+ "appId": "",
+ "routePath": "S40",
+ "sodium": "",
+ "xxid": ""
+ }
+}
+```
+
+**Response** (decrypted with `key1`):
+```json
+{
+ "success": true,
+ "reasonCode": "201",
+ "reasonText": "Key generated successfully.",
+ "smod": "",
+ "nonceGenerator": "",
+ "xxid": "",
+ "encMethod": 2
+}
+```
+
+After this step:
+- Derive `session_key_2 = derive_session_key(smod)`
+- Replace `xxid` and `nonceGenerator` with new values
+
+---
+
+### [5] Get Auth Type — `sfunc=n`, `routePath: A44`
+
+Same as step [1] but with `session_key_2`. Fetches `userSalt` for password hashing.
+
+---
+
+### [6] Regular Login Init — `sfunc=n`, `routePath: A41`
+
+**Key**: `session_key_2`
+
+**Request**:
+```json
+{
+ "sfunc": "n",
+ "xxid": "",
+ "data": {
+ "uname": "",
+ "pgf03": "",
+ "clientSalt": "",
+ "pmodTime": 0,
+ "requireBankData": 1,
+ "nonce": "",
+ "appId": "",
+ "sodium": "",
+ "routePath": "A41",
+ "xxid": ""
+ }
+}
+```
+
+**Response**:
+```json
+{
+ "success": true,
+ "reasonCode": "104",
+ "reasonText": "Initialization Successful",
+ "primaryOTPType": "3",
+ "roleName": "Consumer Premium",
+ "otpTypes": [2, 3],
+ "email": "",
+ "uuid": "",
+ "uuid2": "",
+ "xxid": ""
+}
+```
+
+---
+
+### [7] Get Profile Image — `sfunc=n`, `routePath: P41`
+
+**Key**: `session_key_2`
+
+**Request**:
+```json
+{
+ "sfunc": "n",
+ "xxid": "",
+ "data": {
+ "imageHash": "",
+ "nonce": "",
+ "appId": "",
+ "sodium": "",
+ "routePath": "P41",
+ "xxid": ""
+ }
+}
+```
+
+**Response**:
+```json
+{
+ "success": true,
+ "reasonCode": "201",
+ "reasonText": "Image Found",
+ "profileImage": ""
+}
+```
+
+---
+
+### [8] OTP Verification (Login) — `sfunc=n`, `routePath: A42`
+
+**Key**: `session_key_2`
+
+**Request**:
+```json
+{
+ "sfunc": "n",
+ "xxid": "",
+ "data": {
+ "otp": "<6-digit OTP>",
+ "uname": "",
+ "otpType": "3",
+ "nonce": "",
+ "appId": "",
+ "sodium": "",
+ "routePath": "A42",
+ "xxid": ""
+ }
+}
+```
+
+---
+
+## Password Hashing (`pgf03`)
+
+The password is never sent in plaintext. The scheme prevents replay attacks by
+mixing in a server-provided salt and a client-generated random salt.
+
+```
+pgf03 = SHA256( clientSalt + SHA256( userSalt + SHA256( password ) ) )
+```
+
+All SHA256 values are uppercase hex strings.
+
+```python
+import hashlib
+
+def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
+ h1 = hashlib.sha256(password.encode()).hexdigest().upper()
+ h2 = hashlib.sha256((user_salt + h1).encode()).hexdigest().upper()
+ h3 = hashlib.sha256((client_salt + h2).encode()).hexdigest().upper()
+ return h3
+```
+
+---
+
+## Route Paths Summary
+
+| routePath | sfunc | Description |
+|---|---|---|
+| `S40` | `r` or `i` | DH key exchange |
+| `A44` | `n` | Get auth type / userSalt |
+| `A41` | `n` | Regular login initialization |
+| `A42` | `n` | OTP verification (regular login) |
+| `C41` | `n` | Device registration initialization |
+| `C42` | `n` | OTP verification (registration) |
+| `P41` | `n` | Get profile image |
+| `P40` | `n` | Update profile image |
+| `P42` | `n` | Delete profile image |
diff --git a/docs/mibapi/decrypt.py b/docs/mibapi/decrypt.py
new file mode 100755
index 0000000..b9e912c
--- /dev/null
+++ b/docs/mibapi/decrypt.py
@@ -0,0 +1,80 @@
+#!/usr/bin/env python
+"""
+Faisanet MIB API decryption tool.
+Usage:
+ ./decrypt.py
+ ./decrypt.py --key
+ ./decrypt.py --smod
+"""
+
+import sys
+import json
+import base64
+import hashlib
+import argparse
+from urllib.parse import unquote
+
+try:
+ from Crypto.Cipher import Blowfish
+ from Crypto.Util.Padding import unpad
+except ImportError:
+ print("Missing dependency. Run: pip install pycryptodome", file=sys.stderr)
+ sys.exit(1)
+
+DEFAULT_KEY = '8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678'
+
+# Hardcoded DH parameters from app
+# NOTE: the variable names in the app's source are misleading —
+# A_VALUE is the exponent (client private key), the shorter number
+# P_VALUE is the prime modulus, the longer number
+A_VALUE = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
+P_VALUE = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
+
+
+def derive_session_key(smod: int) -> str:
+ shared_secret = pow(smod, A_VALUE, P_VALUE)
+ sha256_hex = hashlib.sha256(str(shared_secret).encode()).hexdigest().upper()
+ return base64.b64encode(bytes.fromhex(sha256_hex)).decode()
+
+
+def decrypt(ciphertext: str, key: str) -> str:
+ # Strip URL encoding if present
+ ciphertext = unquote(ciphertext).strip()
+ key_bytes = key.encode('latin-1')
+ ct_bytes = base64.b64decode(ciphertext)
+ cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
+ plaintext = unpad(cipher.decrypt(ct_bytes), Blowfish.block_size)
+ return plaintext.decode('utf-8')
+
+
+def main():
+ parser = argparse.ArgumentParser(description='Decrypt Faisanet MIB API payloads')
+ parser.add_argument('ciphertext', help='Base64 (or URL-encoded) encrypted payload')
+ parser.add_argument('--key', default=None, help='Blowfish key (default: hardcoded DEFAULT_KEY)')
+ parser.add_argument('--smod', default=None, help='Server DH public key (decimal) to derive session key')
+ args = parser.parse_args()
+
+ if args.smod:
+ key = derive_session_key(int(args.smod))
+ print(f"[derived session key] {key}", file=sys.stderr)
+ elif args.key:
+ key = args.key
+ else:
+ key = DEFAULT_KEY
+ print(f"[using DEFAULT_KEY]", file=sys.stderr)
+
+ try:
+ plaintext = decrypt(args.ciphertext, key)
+ except Exception as e:
+ print(f"Decryption failed: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ # Pretty-print if JSON
+ try:
+ print(json.dumps(json.loads(plaintext), indent=2))
+ except Exception:
+ print(plaintext)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/mib_faisanet_logo.svg b/mib_faisanet_logo.svg
new file mode 100644
index 0000000..8be40b9
--- /dev/null
+++ b/mib_faisanet_logo.svg
@@ -0,0 +1,29 @@
+
+
+
+