forked from shihaam/thijooree
working mib login and list accounts
This commit is contained in:
@@ -13,3 +13,6 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
docs/mibapi/tmp
|
||||
docs/bmlapi/tmp
|
||||
tmp
|
||||
|
||||
Generated
+18
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2025-04-17T17:14:16.980728222Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+13
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+1
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
|
||||
Generated
-1
@@ -1,4 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
+15
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".BasedBankApp"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
@@ -11,18 +15,32 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.BasedBank"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
tools:targetApi="31">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.BasedBank.NoActionBar">
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.onboarding.OnboardingActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.login.LoginActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.home.HomeActivity"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package sh.sar.basedbank
|
||||
|
||||
import android.app.Application
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
|
||||
class BasedBankApp : Application() {
|
||||
|
||||
// Held in memory after successful login; cleared on logout
|
||||
var accounts: List<MibAccount> = emptyList()
|
||||
var fullName: String = ""
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,19 @@
|
||||
package sh.sar.basedbank
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.navigation.NavigationView
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.ui.AppBarConfiguration
|
||||
import androidx.navigation.ui.navigateUp
|
||||
import androidx.navigation.ui.setupActionBarWithNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import sh.sar.basedbank.databinding.ActivityMainBinding
|
||||
import sh.sar.basedbank.ui.login.LoginActivity
|
||||
import sh.sar.basedbank.ui.onboarding.OnboardingActivity
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var appBarConfiguration: AppBarConfiguration
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
setSupportActionBar(binding.appBarMain.toolbar)
|
||||
|
||||
binding.appBarMain.fab.setOnClickListener { view ->
|
||||
Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
|
||||
.setAction("Action", null)
|
||||
.setAnchorView(R.id.fab).show()
|
||||
}
|
||||
val drawerLayout: DrawerLayout = binding.drawerLayout
|
||||
val navView: NavigationView = binding.navView
|
||||
val navController = findNavController(R.id.nav_host_fragment_content_main)
|
||||
// Passing each menu ID as a set of Ids because each
|
||||
// menu should be considered as top level destinations.
|
||||
appBarConfiguration = AppBarConfiguration(
|
||||
setOf(
|
||||
R.id.nav_home, R.id.nav_gallery, R.id.nav_slideshow
|
||||
), drawerLayout
|
||||
)
|
||||
setupActionBarWithNavController(navController, appBarConfiguration)
|
||||
navView.setupWithNavController(navController)
|
||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
||||
val target = if (onboardingDone) LoginActivity::class.java else OnboardingActivity::class.java
|
||||
startActivity(Intent(this, target))
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
// Inflate the menu; this adds items to the action bar if it is present.
|
||||
menuInflater.inflate(R.menu.main, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
val navController = findNavController(R.id.nav_host_fragment_content_main)
|
||||
return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.util.Base64
|
||||
import org.json.JSONObject
|
||||
import java.math.BigInteger
|
||||
import java.security.MessageDigest
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
object MibCrypto {
|
||||
|
||||
const val DEFAULT_KEY = "8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678"
|
||||
|
||||
private val A = BigInteger(
|
||||
"1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577"
|
||||
)
|
||||
private val P = BigInteger(
|
||||
"2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919"
|
||||
)
|
||||
|
||||
// cmod = G^A mod P (sent in every DH key exchange request)
|
||||
val CMOD: String = BigInteger.TWO.modPow(A, P).toString()
|
||||
|
||||
fun deriveSessionKey(smod: String): String {
|
||||
val shared = BigInteger(smod).modPow(A, P)
|
||||
val sha256hex = MessageDigest.getInstance("SHA-256")
|
||||
.digest(shared.toString().toByteArray())
|
||||
.joinToString("") { "%02X".format(it) }
|
||||
val keyBytes = sha256hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
return Base64.encodeToString(keyBytes, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun encrypt(json: JSONObject, key: String): String {
|
||||
val plaintext = json.toString().toByteArray(Charsets.UTF_8)
|
||||
val keyBytes = key.toByteArray(Charsets.ISO_8859_1)
|
||||
val cipher = Cipher.getInstance("Blowfish/ECB/PKCS5Padding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyBytes, "Blowfish"))
|
||||
val ct = cipher.doFinal(plaintext)
|
||||
return Base64.encodeToString(ct, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun decrypt(ciphertextB64: String, key: String): JSONObject {
|
||||
val keyBytes = key.toByteArray(Charsets.ISO_8859_1)
|
||||
val ct = Base64.decode(ciphertextB64, Base64.DEFAULT)
|
||||
val cipher = Cipher.getInstance("Blowfish/ECB/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(keyBytes, "Blowfish"))
|
||||
val plaintext = cipher.doFinal(ct)
|
||||
return JSONObject(String(plaintext, Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.FormBody
|
||||
import sh.sar.basedbank.util.Totp
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
|
||||
class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
|
||||
private val TAG = "MibLoginFlow"
|
||||
private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor { chain ->
|
||||
val req = chain.request().newBuilder()
|
||||
.header("User-Agent", "android/1.0")
|
||||
.header("Accept", "application/json")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
|
||||
.build()
|
||||
chain.proceed(req)
|
||||
}
|
||||
.build()
|
||||
|
||||
// ─── Public entry point ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Full login flow. Automatically handles first-time device registration
|
||||
* vs. subsequent logins using stored key1/key2.
|
||||
*
|
||||
* Returns list of accounts from all profiles on success.
|
||||
*/
|
||||
fun login(username: String, password: String, otpSeed: String): List<MibAccount> {
|
||||
val appId = getOrCreateAppId()
|
||||
Log.d(TAG, "login: appId=$appId")
|
||||
val key1 = prefs.getString("mib_key1_$username", null)
|
||||
val key2 = prefs.getString("mib_key2_$username", null)
|
||||
Log.d(TAG, "login: stored keys present=${key1 != null && key2 != null}")
|
||||
|
||||
return if (key1 != null && key2 != null) {
|
||||
Log.d(TAG, "login: taking regular login path")
|
||||
regularLogin(username, password, appId, key1, key2)
|
||||
} else {
|
||||
Log.d(TAG, "login: taking first-time registration path")
|
||||
firstTimeRegistration(username, password, otpSeed, appId)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── First-time registration ──────────────────────────────────────────────
|
||||
|
||||
private fun firstTimeRegistration(
|
||||
username: String, password: String, otpSeed: String, appId: String
|
||||
): List<MibAccount> {
|
||||
Log.d(TAG, "[reg] step 0: key exchange (sfunc=r)")
|
||||
val (session1, _) = initialKeyExchange(appId, MibCrypto.DEFAULT_KEY, "r")
|
||||
Log.d(TAG, "[reg] step 0 done: xxid=${session1.xxid}")
|
||||
|
||||
Log.d(TAG, "[reg] step 1: getAuthType (A44)")
|
||||
val userSalt = getAuthType(session1, username)
|
||||
Log.d(TAG, "[reg] step 1 done: userSalt length=${userSalt.length}")
|
||||
|
||||
Log.d(TAG, "[reg] step 2: registration init (C41)")
|
||||
Log.d(TAG, "[reg] username='$username' password='$password' userSalt='$userSalt'")
|
||||
val clientSalt = randomAlpha(32)
|
||||
val pgf03 = computePgf03(password, userSalt, clientSalt)
|
||||
Log.d(TAG, "[reg] pgf03=$pgf03")
|
||||
val regInitPayload = baseData(session1, "C41").apply {
|
||||
put("uname", username)
|
||||
put("pgf03", pgf03)
|
||||
put("clientSalt", clientSalt)
|
||||
}
|
||||
val regInitResp = doRequest(session1, regInitPayload, "n")
|
||||
Log.d(TAG, "[reg] step 2 response: $regInitResp")
|
||||
check(regInitResp.optBoolean("success", false)) {
|
||||
regInitResp.optString("reasonText", "Registration init failed")
|
||||
}
|
||||
|
||||
Log.d(TAG, "[reg] step 3: OTP verify (C42)")
|
||||
val otp = generateOtp(otpSeed)
|
||||
Log.d(TAG, "[reg] generated OTP=$otp")
|
||||
val otpPayload = baseData(session1, "C42").apply {
|
||||
put("otp", otp)
|
||||
put("uname", username)
|
||||
put("otpType", "3")
|
||||
}
|
||||
val otpResp = doRequest(session1, otpPayload, "n")
|
||||
Log.d(TAG, "[reg] step 3 response: $otpResp")
|
||||
check(otpResp.optBoolean("success", false)) {
|
||||
otpResp.optString("reasonText", "OTP verification failed")
|
||||
}
|
||||
|
||||
val keyData = otpResp.getJSONArray("data").getJSONObject(0)
|
||||
val key1 = keyData.getString("key1")
|
||||
val key2 = keyData.getString("key2")
|
||||
Log.d(TAG, "[reg] stored key1/key2 for user=$username")
|
||||
prefs.edit().putString("mib_key1_$username", key1).putString("mib_key2_$username", key2).apply()
|
||||
|
||||
return regularLogin(username, password, appId, key1, key2)
|
||||
}
|
||||
|
||||
// ─── Regular login ────────────────────────────────────────────────────────
|
||||
|
||||
private fun regularLogin(
|
||||
username: String, password: String,
|
||||
appId: String, key1: String, key2: String
|
||||
): List<MibAccount> {
|
||||
Log.d(TAG, "[login] step 4: key exchange (sfunc=i)")
|
||||
val (session2, _) = initialKeyExchange(appId, key1, "i", key2)
|
||||
Log.d(TAG, "[login] step 4 done: xxid=${session2.xxid}")
|
||||
|
||||
Log.d(TAG, "[login] step 5: getAuthType (A44)")
|
||||
val userSalt = getAuthType(session2, username)
|
||||
Log.d(TAG, "[login] step 5 done: userSalt length=${userSalt.length}")
|
||||
|
||||
Log.d(TAG, "[login] step 6: login init (A41)")
|
||||
val clientSalt = randomAlpha(32)
|
||||
val pgf03 = computePgf03(password, userSalt, clientSalt)
|
||||
Log.d(TAG, "[login] pgf03 length=${pgf03.length}")
|
||||
val loginPayload = baseData(session2, "A41").apply {
|
||||
put("uname", username)
|
||||
put("pgf03", pgf03)
|
||||
put("clientSalt", clientSalt)
|
||||
put("pmodTime", 0)
|
||||
put("requireBankData", 1)
|
||||
}
|
||||
val loginResp = doRequest(session2, loginPayload, "n")
|
||||
Log.d(TAG, "[login] step 6 response: success=${loginResp.optBoolean("success")} reasonCode=${loginResp.optString("reasonCode")} reasonText=${loginResp.optString("reasonText")}")
|
||||
check(loginResp.optBoolean("success", false)) {
|
||||
loginResp.optString("reasonText", "Login init failed")
|
||||
}
|
||||
|
||||
val profiles = parseProfiles(loginResp)
|
||||
Log.d(TAG, "[login] parsed ${profiles.size} profiles")
|
||||
|
||||
Log.d(TAG, "[login] step 7: fetch all profiles")
|
||||
return fetchAllProfiles(session2, profiles)
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun initialKeyExchange(
|
||||
appId: String, encKey: String, sfunc: String, key2: String? = null
|
||||
): Pair<MibSession, String> {
|
||||
val innerPayload = JSONObject().apply {
|
||||
put("cmod", MibCrypto.CMOD)
|
||||
put("appId", appId)
|
||||
put("routePath", "S40")
|
||||
put("sodium", MibNonce.randomSodium())
|
||||
put("xxid", MibNonce.randomXxid())
|
||||
}
|
||||
|
||||
val encrypted = MibCrypto.encrypt(innerPayload, encKey)
|
||||
val formBody = FormBody.Builder()
|
||||
.add("sfunc", sfunc)
|
||||
.apply { if (key2 != null) add("key2", key2) }
|
||||
.add("data", encrypted)
|
||||
.build()
|
||||
|
||||
val response = post(formBody)
|
||||
Log.d(TAG, "keyExchange($sfunc) raw response (first 80): ${response.take(80)}")
|
||||
val respJson = MibCrypto.decrypt(response, encKey)
|
||||
Log.d(TAG, "keyExchange($sfunc) decrypted: success=${respJson.optBoolean("success")} reasonText=${respJson.optString("reasonText")}")
|
||||
|
||||
check(respJson.optBoolean("success", false)) {
|
||||
respJson.optString("reasonText", "Key exchange failed")
|
||||
}
|
||||
|
||||
val smod = respJson.getString("smod")
|
||||
val sessionKey = MibCrypto.deriveSessionKey(smod)
|
||||
val xxid = respJson.getString("xxid")
|
||||
val nonceGen = respJson.getString("nonceGenerator")
|
||||
|
||||
return Pair(MibSession(appId, xxid, nonceGen, sessionKey), xxid)
|
||||
}
|
||||
|
||||
private fun getAuthType(session: MibSession, username: String): String {
|
||||
val payload = baseData(session, "A44").apply {
|
||||
put("uname", username)
|
||||
}
|
||||
val resp = doRequest(session, payload, "n")
|
||||
return resp.getJSONArray("data").getJSONObject(0).getString("userSalt")
|
||||
}
|
||||
|
||||
private fun doRequest(session: MibSession, data: JSONObject, sfunc: String): JSONObject {
|
||||
val routePath = data.optString("routePath", "?")
|
||||
Log.d(TAG, "doRequest: routePath=$routePath xxid=${session.xxid.take(16)}...")
|
||||
val encrypted = MibCrypto.encrypt(data, session.sessionKey)
|
||||
val formBody = FormBody.Builder()
|
||||
.add("xxid", session.xxid)
|
||||
.add("sfunc", sfunc)
|
||||
.add("data", encrypted)
|
||||
.build()
|
||||
val response = post(formBody)
|
||||
Log.d(TAG, "doRequest($routePath) raw response (first 80): ${response.take(80)}")
|
||||
val result = MibCrypto.decrypt(response, session.sessionKey)
|
||||
Log.d(TAG, "doRequest($routePath) decrypted: success=${result.optBoolean("success")} reasonText=${result.optString("reasonText")}")
|
||||
return result
|
||||
}
|
||||
|
||||
private fun baseData(session: MibSession, routePath: String): JSONObject = JSONObject().apply {
|
||||
put("nonce", MibNonce.generate(session.nonceGenerator))
|
||||
put("appId", session.appId)
|
||||
put("sodium", MibNonce.randomSodium())
|
||||
put("routePath", routePath)
|
||||
put("xxid", session.xxid)
|
||||
}
|
||||
|
||||
private fun fetchAllProfiles(session: MibSession, profiles: List<MibProfile>): List<MibAccount> {
|
||||
val allAccounts = mutableListOf<MibAccount>()
|
||||
for (profile in profiles) {
|
||||
val payload = baseData(session, "P47").apply {
|
||||
put("profileType", profile.profileType)
|
||||
put("profileId", profile.profileId)
|
||||
}
|
||||
val resp = doRequest(session, payload, "n")
|
||||
if (!resp.optBoolean("success", false)) {
|
||||
Log.w(TAG, "P47 failed for profile ${profile.name}: ${resp.optString("reasonText")}")
|
||||
continue
|
||||
}
|
||||
val accountBalances = resp.optJSONArray("accountBalance") ?: continue
|
||||
for (i in 0 until accountBalances.length()) {
|
||||
val a = accountBalances.getJSONObject(i)
|
||||
allAccounts.add(
|
||||
MibAccount(
|
||||
profileName = profile.name,
|
||||
profileType = profile.profileType,
|
||||
accountNumber = a.optString("accountNumber"),
|
||||
accountBriefName = a.optString("accountBriefName"),
|
||||
currencyName = a.optString("currencyName"),
|
||||
accountTypeName = a.optString("accountTypeName"),
|
||||
availableBalance = a.optString("availableBalance"),
|
||||
currentBalance = a.optString("currentBalance"),
|
||||
blockedAmount = a.optString("blockedAmount"),
|
||||
mvrBalance = a.optString("mvrBalance"),
|
||||
statusDesc = a.optString("statusDesc")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return allAccounts
|
||||
}
|
||||
|
||||
private fun parseProfiles(loginResp: JSONObject): List<MibProfile> {
|
||||
val arr = loginResp.optJSONArray("operatingProfiles") ?: return emptyList()
|
||||
return (0 until arr.length()).map { i ->
|
||||
val p = arr.getJSONObject(i)
|
||||
MibProfile(
|
||||
profileId = p.optString("profileId"),
|
||||
customerProfileId = p.optString("customerProfileId"),
|
||||
annexId = p.optString("annexId"),
|
||||
customerId = p.optString("customerId"),
|
||||
name = p.optString("name"),
|
||||
cifType = p.optString("cifType"),
|
||||
profileType = p.optString("profileType"),
|
||||
color = p.optString("color")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun post(body: FormBody): String {
|
||||
val request = Request.Builder()
|
||||
.url(BASE_URL)
|
||||
.post(body)
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
return response.body?.string() ?: throw IllegalStateException("Empty response body")
|
||||
}
|
||||
|
||||
private fun computePgf03(password: String, userSalt: String, clientSalt: String): String {
|
||||
fun sha256Upper(input: String) = MessageDigest.getInstance("SHA-256")
|
||||
.digest(input.toByteArray())
|
||||
.joinToString("") { "%02X".format(it) }
|
||||
|
||||
val h1 = sha256Upper(password)
|
||||
val h2 = sha256Upper(h1 + userSalt)
|
||||
return sha256Upper(clientSalt + h2)
|
||||
}
|
||||
|
||||
private fun generateOtp(seed: String): String = Totp.generate(seed)
|
||||
|
||||
private fun getOrCreateAppId(): String {
|
||||
var id = prefs.getString("mib_app_id", null)
|
||||
if (id == null) {
|
||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
|
||||
prefs.edit().putString("mib_app_id", id).apply()
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
private fun randomAlpha(length: Int): String {
|
||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
return (1..length).map { chars[Random.nextInt(chars.length)] }.joinToString("")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
data class MibSession(
|
||||
val appId: String,
|
||||
val xxid: String,
|
||||
val nonceGenerator: String,
|
||||
val sessionKey: String
|
||||
)
|
||||
|
||||
data class MibProfile(
|
||||
val profileId: String,
|
||||
val customerProfileId: String,
|
||||
val annexId: String,
|
||||
val customerId: String,
|
||||
val name: String,
|
||||
val cifType: String,
|
||||
val profileType: String,
|
||||
val color: String
|
||||
)
|
||||
|
||||
data class MibAccount(
|
||||
val profileName: String,
|
||||
val profileType: String,
|
||||
val accountNumber: String,
|
||||
val accountBriefName: String,
|
||||
val currencyName: String,
|
||||
val accountTypeName: String,
|
||||
val availableBalance: String,
|
||||
val currentBalance: String,
|
||||
val blockedAmount: String,
|
||||
val mvrBalance: String,
|
||||
val statusDesc: String
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import kotlin.math.floor
|
||||
import kotlin.random.Random
|
||||
|
||||
object MibNonce {
|
||||
|
||||
/**
|
||||
* Generate a nonce string from the nonceGenerator returned by the DH key exchange.
|
||||
*
|
||||
* Phase 1: for each group, take the first token's number * random(1-99), pad to 5 digits.
|
||||
* Collect lastTwo (last 2 digits), digitSum (sum of all digits), cumSum.
|
||||
* Phase 2: for each group, tokens 1-7 compute values using the operation letter.
|
||||
* Each result's last 2 digits become the nonce digit and carry for next step.
|
||||
*
|
||||
* Operations: M=(carry%num)+ds+cs, A=carry+num+ds+cs, S=(carry^2)+num+ds+cs,
|
||||
* X=(carry*num)+ds+cs, C=(carry^3)+num+ds+cs
|
||||
*/
|
||||
fun generate(nonceGenerator: String): String {
|
||||
val groups = nonceGenerator.split("-")
|
||||
|
||||
val paddedList = mutableListOf<String>()
|
||||
val lastTwoList = mutableListOf<Int>()
|
||||
val digitSumList = mutableListOf<Int>()
|
||||
var cumSum = 0
|
||||
|
||||
// Phase 1
|
||||
for (group in groups) {
|
||||
val tokens = group.trim().split(" ")
|
||||
val n = tokens[0].filter { it.isDigit() }.toInt()
|
||||
val r = floor(Random.nextDouble() * 99).toInt() + 1
|
||||
val product = n * r
|
||||
val padded = product.toString().padStart(5, '0')
|
||||
val ds = padded.sumOf { it.digitToInt() }
|
||||
val lt = padded.takeLast(2).toInt()
|
||||
paddedList.add(padded)
|
||||
lastTwoList.add(lt)
|
||||
digitSumList.add(ds)
|
||||
cumSum += ds
|
||||
}
|
||||
|
||||
// Phase 2
|
||||
val resultGroups = mutableListOf<String>()
|
||||
for ((i, group) in groups.withIndex()) {
|
||||
val tokens = group.trim().split(" ")
|
||||
var carry = lastTwoList[i]
|
||||
val ds = digitSumList[i]
|
||||
val nonceDigits = mutableListOf<Int>()
|
||||
|
||||
for (j in 1..7) {
|
||||
val token = tokens[j]
|
||||
val op = token.filter { it.isLetter() }
|
||||
val num = token.filter { it.isDigit() }.toInt()
|
||||
val value: Long = when (op) {
|
||||
"M" -> (carry % num).toLong() + ds + cumSum
|
||||
"A" -> carry.toLong() + num + ds + cumSum
|
||||
"S" -> (carry.toLong() * carry) + num + ds + cumSum
|
||||
"X" -> carry.toLong() * num + ds + cumSum
|
||||
"C" -> (carry.toLong() * carry * carry) + num + ds + cumSum
|
||||
else -> 0L
|
||||
}
|
||||
val digit = value.toString().takeLast(2).toInt()
|
||||
nonceDigits.add(digit)
|
||||
carry = digit
|
||||
}
|
||||
|
||||
val groupStr = paddedList[i] + " " + nonceDigits.joinToString(" ") {
|
||||
it.toString().padStart(2, '0')
|
||||
}
|
||||
resultGroups.add(groupStr)
|
||||
}
|
||||
|
||||
return resultGroups.joinToString("-")
|
||||
}
|
||||
|
||||
fun randomSodium(): String = (Random.nextLong(1_000_000L, 16_000_000L)).toString()
|
||||
|
||||
fun randomXxid(): String = Random.nextLong(0L, (1L shl 40)).toString()
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package sh.sar.basedbank.ui.gallery
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import sh.sar.basedbank.databinding.FragmentGalleryBinding
|
||||
|
||||
class GalleryFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentGalleryBinding? = null
|
||||
|
||||
// This property is only valid between onCreateView and
|
||||
// onDestroyView.
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val galleryViewModel =
|
||||
ViewModelProvider(this).get(GalleryViewModel::class.java)
|
||||
|
||||
_binding = FragmentGalleryBinding.inflate(inflater, container, false)
|
||||
val root: View = binding.root
|
||||
|
||||
val textView: TextView = binding.textGallery
|
||||
galleryViewModel.text.observe(viewLifecycleOwner) {
|
||||
textView.text = it
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package sh.sar.basedbank.ui.gallery
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class GalleryViewModel : ViewModel() {
|
||||
|
||||
private val _text = MutableLiveData<String>().apply {
|
||||
value = "This is gallery Fragment"
|
||||
}
|
||||
val text: LiveData<String> = _text
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.databinding.ItemAccountBinding
|
||||
import sh.sar.basedbank.databinding.ItemProfileHeaderBinding
|
||||
|
||||
class AccountsAdapter(private val accounts: List<MibAccount>) :
|
||||
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
data class Header(val profileName: String, val profileType: String) : Item()
|
||||
data class Account(val account: MibAccount) : Item()
|
||||
}
|
||||
|
||||
private val items: List<Item> = buildList {
|
||||
var lastProfile = ""
|
||||
for (account in accounts) {
|
||||
if (account.profileName != lastProfile) {
|
||||
add(Item.Header(account.profileName, account.profileType))
|
||||
lastProfile = account.profileName
|
||||
}
|
||||
add(Item.Account(account))
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int) = when (items[position]) {
|
||||
is Item.Header -> TYPE_HEADER
|
||||
is Item.Account -> TYPE_ACCOUNT
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return if (viewType == TYPE_HEADER) {
|
||||
HeaderViewHolder(ItemProfileHeaderBinding.inflate(inflater, parent, false))
|
||||
} else {
|
||||
AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (val item = items[position]) {
|
||||
is Item.Header -> (holder as HeaderViewHolder).bind(item)
|
||||
is Item.Account -> (holder as AccountViewHolder).bind(item.account)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
private inner class HeaderViewHolder(private val binding: ItemProfileHeaderBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(item: Item.Header) {
|
||||
binding.tvProfileName.text = item.profileName
|
||||
binding.tvProfileType.text = if (item.profileType == "0") "Personal" else "Business"
|
||||
}
|
||||
}
|
||||
|
||||
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(account: MibAccount) {
|
||||
binding.tvAccountName.text = account.accountBriefName
|
||||
binding.tvAccountNumber.text = account.accountNumber
|
||||
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
|
||||
binding.tvAccountType.text = account.accountTypeName
|
||||
binding.tvStatus.text = account.statusDesc
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TYPE_HEADER = 0
|
||||
private const val TYPE_ACCOUNT = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.databinding.ActivityHomeBinding
|
||||
|
||||
class HomeActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityHomeBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityHomeBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
setSupportActionBar(binding.toolbar)
|
||||
|
||||
val accounts = (application as BasedBankApp).accounts
|
||||
val adapter = AccountsAdapter(accounts)
|
||||
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
binding.recyclerView.adapter = adapter
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import sh.sar.basedbank.databinding.FragmentHomeBinding
|
||||
|
||||
class HomeFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentHomeBinding? = null
|
||||
|
||||
// This property is only valid between onCreateView and
|
||||
// onDestroyView.
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val homeViewModel =
|
||||
ViewModelProvider(this).get(HomeViewModel::class.java)
|
||||
|
||||
_binding = FragmentHomeBinding.inflate(inflater, container, false)
|
||||
val root: View = binding.root
|
||||
|
||||
val textView: TextView = binding.textHome
|
||||
homeViewModel.text.observe(viewLifecycleOwner) {
|
||||
textView.text = it
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
|
||||
private val _text = MutableLiveData<String>().apply {
|
||||
value = "This is home Fragment"
|
||||
}
|
||||
val text: LiveData<String> = _text
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package sh.sar.basedbank.ui.login
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentBankSelectionBinding
|
||||
|
||||
class BankSelectionFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentBankSelectionBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentBankSelectionBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
binding.cardMib.setOnClickListener {
|
||||
findNavController().navigate(R.id.action_bankSelection_to_credentials)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package sh.sar.basedbank.ui.login
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.util.Log
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.util.Totp
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
|
||||
class CredentialsFragment : Fragment() {
|
||||
|
||||
private val TAG = "CredentialsFragment"
|
||||
private val otpHandler = Handler(Looper.getMainLooper())
|
||||
private val otpRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
updateOtpDisplay()
|
||||
otpHandler.postDelayed(this, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
private var _binding: FragmentCredentialsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
binding.btnLogin.setOnClickListener { attemptLogin() }
|
||||
|
||||
binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) { updateOtpDisplay() }
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
otpHandler.post(otpRunnable)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
otpHandler.removeCallbacks(otpRunnable)
|
||||
}
|
||||
|
||||
private fun updateOtpDisplay() {
|
||||
val seed = binding.etOtpSeed.text.toString().trim()
|
||||
if (seed.isEmpty()) {
|
||||
binding.cardOtp.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
try {
|
||||
val otp = Totp.generate(seed)
|
||||
val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt()
|
||||
val remaining = 30 - secondsInPeriod
|
||||
|
||||
binding.tvOtpCode.text = otp
|
||||
binding.otpTimer.max = 30
|
||||
binding.otpTimer.progress = remaining
|
||||
binding.cardOtp.visibility = View.VISIBLE
|
||||
} catch (e: Exception) {
|
||||
binding.cardOtp.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun attemptLogin() {
|
||||
val username = binding.etUsername.text.toString().trim()
|
||||
val password = binding.etPassword.text.toString()
|
||||
val otpSeed = binding.etOtpSeed.text.toString().trim()
|
||||
|
||||
Log.d(TAG, "Login button pressed for username=$username")
|
||||
|
||||
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
|
||||
Log.w(TAG, "Validation failed: empty fields")
|
||||
binding.tvError.text = "Please fill in all fields"
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
return
|
||||
}
|
||||
|
||||
binding.tvError.visibility = View.GONE
|
||||
binding.progressBar.visibility = View.VISIBLE
|
||||
binding.btnLogin.isEnabled = false
|
||||
|
||||
val prefs = requireContext().getSharedPreferences("mib_prefs", android.content.Context.MODE_PRIVATE)
|
||||
val flow = MibLoginFlow(prefs)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
Log.d(TAG, "Starting login flow on IO dispatcher")
|
||||
val accounts = withContext(Dispatchers.IO) {
|
||||
flow.login(username, password, otpSeed)
|
||||
}
|
||||
Log.d(TAG, "Login succeeded, got ${accounts.size} accounts")
|
||||
(requireActivity().application as BasedBankApp).accounts = accounts
|
||||
startActivity(Intent(requireContext(), HomeActivity::class.java))
|
||||
requireActivity().finish()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Login failed: ${e.message}", e)
|
||||
binding.tvError.text = e.message ?: "Login failed"
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
} finally {
|
||||
binding.progressBar.visibility = View.GONE
|
||||
binding.btnLogin.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package sh.sar.basedbank.ui.login
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.ActivityLoginBinding
|
||||
|
||||
class LoginActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityLoginBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityLoginBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package sh.sar.basedbank.ui.onboarding
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import sh.sar.basedbank.databinding.ActivityOnboardingBinding
|
||||
import sh.sar.basedbank.ui.login.LoginActivity
|
||||
|
||||
class OnboardingActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityOnboardingBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityOnboardingBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
val adapter = OnboardingPagerAdapter(this)
|
||||
binding.viewPager.adapter = adapter
|
||||
|
||||
TabLayoutMediator(binding.dotsIndicator, binding.viewPager) { _, _ -> }.attach()
|
||||
|
||||
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
updateButtons(position, adapter.itemCount)
|
||||
}
|
||||
})
|
||||
updateButtons(0, adapter.itemCount)
|
||||
|
||||
binding.btnNext.setOnClickListener {
|
||||
val next = binding.viewPager.currentItem + 1
|
||||
if (next < adapter.itemCount) binding.viewPager.currentItem = next
|
||||
}
|
||||
|
||||
binding.btnGetStarted.setOnClickListener {
|
||||
getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
.edit().putBoolean("onboarding_done", true).apply()
|
||||
startActivity(Intent(this, LoginActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateButtons(position: Int, count: Int) {
|
||||
val isLast = position == count - 1
|
||||
binding.btnNext.visibility = if (isLast) View.GONE else View.VISIBLE
|
||||
binding.btnGetStarted.visibility = if (isLast) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package sh.sar.basedbank.ui.onboarding
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import sh.sar.basedbank.databinding.FragmentOnboardingSlideBinding
|
||||
|
||||
class OnboardingFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentOnboardingSlideBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentOnboardingSlideBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val title = requireArguments().getString(ARG_TITLE, "")
|
||||
val desc = requireArguments().getString(ARG_DESC, "")
|
||||
val icon = requireArguments().getInt(ARG_ICON, 0)
|
||||
val isFirst = requireArguments().getBoolean(ARG_IS_FIRST, false)
|
||||
|
||||
binding.icon.setImageResource(icon)
|
||||
binding.title.text = title
|
||||
binding.description.text = desc
|
||||
|
||||
// On the first slide, show the two placeholder cards for upcoming banks
|
||||
binding.placeholderCards.visibility = if (isFirst) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_TITLE = "title"
|
||||
private const val ARG_DESC = "desc"
|
||||
private const val ARG_ICON = "icon"
|
||||
private const val ARG_IS_FIRST = "is_first"
|
||||
|
||||
fun newInstance(slide: OnboardingSlide) = OnboardingFragment().apply {
|
||||
arguments = bundleOf(
|
||||
ARG_TITLE to slide.title,
|
||||
ARG_DESC to slide.description,
|
||||
ARG_ICON to slide.iconRes,
|
||||
ARG_IS_FIRST to slide.isFirst
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package sh.sar.basedbank.ui.onboarding
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import sh.sar.basedbank.R
|
||||
|
||||
class OnboardingPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) {
|
||||
|
||||
private val slides = listOf(
|
||||
OnboardingSlide(
|
||||
title = activity.getString(R.string.onboarding_title_1),
|
||||
description = activity.getString(R.string.onboarding_desc_1),
|
||||
iconRes = R.drawable.ic_launcher_foreground,
|
||||
isFirst = true
|
||||
),
|
||||
OnboardingSlide(
|
||||
title = activity.getString(R.string.onboarding_title_2),
|
||||
description = activity.getString(R.string.onboarding_desc_2),
|
||||
iconRes = R.drawable.ic_launcher_foreground,
|
||||
isFirst = false
|
||||
),
|
||||
OnboardingSlide(
|
||||
title = activity.getString(R.string.onboarding_title_3),
|
||||
description = activity.getString(R.string.onboarding_desc_3),
|
||||
iconRes = R.drawable.ic_launcher_foreground,
|
||||
isFirst = false
|
||||
)
|
||||
)
|
||||
|
||||
override fun getItemCount() = slides.size
|
||||
|
||||
override fun createFragment(position: Int): Fragment =
|
||||
OnboardingFragment.newInstance(slides[position])
|
||||
}
|
||||
|
||||
data class OnboardingSlide(
|
||||
val title: String,
|
||||
val description: String,
|
||||
val iconRes: Int,
|
||||
val isFirst: Boolean
|
||||
)
|
||||
@@ -1,42 +0,0 @@
|
||||
package sh.sar.basedbank.ui.slideshow
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import sh.sar.basedbank.databinding.FragmentSlideshowBinding
|
||||
|
||||
class SlideshowFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentSlideshowBinding? = null
|
||||
|
||||
// This property is only valid between onCreateView and
|
||||
// onDestroyView.
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val slideshowViewModel =
|
||||
ViewModelProvider(this).get(SlideshowViewModel::class.java)
|
||||
|
||||
_binding = FragmentSlideshowBinding.inflate(inflater, container, false)
|
||||
val root: View = binding.root
|
||||
|
||||
val textView: TextView = binding.textSlideshow
|
||||
slideshowViewModel.text.observe(viewLifecycleOwner) {
|
||||
textView.text = it
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package sh.sar.basedbank.ui.slideshow
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class SlideshowViewModel : ViewModel() {
|
||||
|
||||
private val _text = MutableLiveData<String>().apply {
|
||||
value = "This is slideshow Fragment"
|
||||
}
|
||||
val text: LiveData<String> = _text
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.experimental.and
|
||||
|
||||
object Totp {
|
||||
|
||||
/**
|
||||
* Generate a 6-digit TOTP code from a Base32-encoded secret (RFC 6238 / RFC 4226).
|
||||
* Uses HmacSHA1, 30-second window, 6 digits — matching standard authenticator apps.
|
||||
*/
|
||||
fun generate(base32Secret: String, digits: Int = 6, periodSeconds: Long = 30): String {
|
||||
val key = base32Decode(base32Secret.uppercase().replace(" ", "").replace("-", ""))
|
||||
val counter = System.currentTimeMillis() / 1000L / periodSeconds
|
||||
val otp = hotp(key, counter, digits)
|
||||
return otp.toString().padStart(digits, '0')
|
||||
}
|
||||
|
||||
private fun hotp(key: ByteArray, counter: Long, digits: Int): Int {
|
||||
val msg = ByteArray(8) { i -> ((counter shr ((7 - i) * 8)) and 0xFF).toByte() }
|
||||
val mac = Mac.getInstance("HmacSHA1")
|
||||
mac.init(SecretKeySpec(key, "HmacSHA1"))
|
||||
val hash = mac.doFinal(msg)
|
||||
|
||||
val offset = (hash[hash.size - 1] and 0x0F).toInt()
|
||||
val code = ((hash[offset].toInt() and 0x7F) shl 24) or
|
||||
((hash[offset + 1].toInt() and 0xFF) shl 16) or
|
||||
((hash[offset + 2].toInt() and 0xFF) shl 8) or
|
||||
(hash[offset + 3].toInt() and 0xFF)
|
||||
|
||||
val modulus = Math.pow(10.0, digits.toDouble()).toInt()
|
||||
return code % modulus
|
||||
}
|
||||
|
||||
private val BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||
|
||||
private fun base32Decode(input: String): ByteArray {
|
||||
val clean = input.trimEnd('=')
|
||||
val output = ByteArray(clean.length * 5 / 8)
|
||||
var buffer = 0
|
||||
var bitsLeft = 0
|
||||
var outIndex = 0
|
||||
for (c in clean) {
|
||||
val v = BASE32_CHARS.indexOf(c)
|
||||
require(v >= 0) { "Invalid Base32 character: $c" }
|
||||
buffer = (buffer shl 5) or v
|
||||
bitsLeft += 5
|
||||
if (bitsLeft >= 8) {
|
||||
bitsLeft -= 8
|
||||
output[outIndex++] = ((buffer shr bitsLeft) and 0xFF).toByte()
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<translate android:fromXDelta="-100%" android:toXDelta="0%"
|
||||
android:duration="300" android:interpolator="@android:interpolator/decelerate_cubic" />
|
||||
</set>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<translate android:fromXDelta="100%" android:toXDelta="0%"
|
||||
android:duration="300" android:interpolator="@android:interpolator/decelerate_cubic" />
|
||||
</set>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<translate android:fromXDelta="0%" android:toXDelta="-100%"
|
||||
android:duration="300" android:interpolator="@android:interpolator/decelerate_cubic" />
|
||||
</set>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<translate android:fromXDelta="0%" android:toXDelta="100%"
|
||||
android:duration="300" android:interpolator="@android:interpolator/decelerate_cubic" />
|
||||
</set>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="100dp" />
|
||||
<solid android:color="?attr/colorSecondaryContainer" />
|
||||
</shape>
|
||||
@@ -1,12 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,12m-3.2,0a3.2,3.2 0,1 1,6.4 0a3.2,3.2 0,1 1,-6.4 0" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M9,2L7.17,4H4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2H9zm3,15c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5z" />
|
||||
</vector>
|
||||
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M22,16V4c0,-1.1 -0.9,-2 -2,-2H8c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2zm-11,-4l2.03,2.71L16,11l4,5H8l3,-4zM2,6v14c0,1.1 0.9,2 2,2h14v-2H4V6H2z" />
|
||||
</vector>
|
||||
@@ -1,9 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6zm16,-4H8c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zm-8,12.5v-9l6,4.5 -6,4.5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="138dp"
|
||||
android:height="40dp"
|
||||
android:viewportWidth="276"
|
||||
android:viewportHeight="80">
|
||||
|
||||
<!-- Blue paths (fil0: #3F65AD) -->
|
||||
<path
|
||||
android:fillColor="#3F65AD"
|
||||
android:pathData="M272.33 28.36l-12.05 0 0 -6.16c0,-1 -0.36,-1.87 -1.08,-2.59 -0.73,-0.73 -1.61,-1.09 -2.63,-1.08 -0.95,0 -1.87,0.41 -2.52,1.11 -0.67,0.71 -1.01,1.58 -1.01,2.56l0 6.16 -4.07 0c-4.95,0 -4.66,6.64 0,6.64l4.07 0 0 27.8c0,5.47 1.19,9.74 3.53,12.67 2.41,3.01 6.48,4.53 12.11,4.53l3.65 0c1.72,0 3.16,-1.2 3.54,-2.81l0 -1.63c-0.38,-1.61 -1.82,-2.81 -3.54,-2.81l-3.65 0c-3.2,0 -5.41,-0.79 -6.56,-2.35 -1.22,-1.65 -1.84,-4.22 -1.84,-7.6l0 -27.8 12.05 0c4.74,0 4.74,-6.64 0,-6.64z" />
|
||||
<path
|
||||
android:fillColor="#3F65AD"
|
||||
android:pathData="M169.85 76.38l0 -41.07 15.31 0c0.74,0 1.47,0.08 2.18,0.27 1.63,0.46 3.04,1.32 4.16,2.55 0.82,0.91 1.49,2.02 1.99,3.29 0.5,1.27 0.76,2.7 0.76,4.24l0 30.72c0,2 1.62,3.62 3.62,3.62l0 0c2,0 3.63,-1.62 3.63,-3.62l0 -30.72c0,-2.72 -0.44,-5.17 -1.3,-7.3 -0.86,-2.14 -2.07,-3.98 -3.59,-5.47 -1.53,-1.49 -3.3,-2.63 -5.27,-3.39 -1.97,-0.76 -4.04,-1.14 -6.17,-1.14l-17.43 0c-1.54,0 -2.8,0.54 -3.75,1.61 -0.92,1.04 -1.38,2.26 -1.38,3.62l0 42.79c0,2 1.62,3.62 3.62,3.62l0 0c2,0 3.62,-1.62 3.62,-3.62z" />
|
||||
<path
|
||||
android:fillColor="#3F65AD"
|
||||
android:pathData="M225.3 28.36c15.78,0 18.49,6.01 19.17,19.96 0.27,5.45 -0.89,8.87 -5.93,9.27l-17.03 0c-1.8,0 -3.27,-1.51 -3.27,-3.37 0,-1.86 1.47,-3.37 3.27,-3.37l0.77 0 12.95 0 2.56 0c0,-9.79 0.21,-16.11 -12.49,-15.54 -3.89,0 -6.8,1.13 -8.92,3.46 -2.11,2.32 -3.18,5.53 -3.18,9.52l0.03 9.51c0.09,2.35 0.32,4.45 0.68,6.25 0.39,2 1.04,3.69 1.93,5.03 0.87,1.29 2.03,2.28 3.47,2.94 1.47,0.68 3.39,1.02 5.69,1.02l2 0 4.51 0 3.48 0c1.77,0.01 3.21,1.54 3.21,3.38 0,1.83 -1.44,3.58 -3.21,3.58l-3.48 0 -4.51 0 -2.09 0c-3.63,0 -6.69,-0.5 -9.11,-1.49 -2.48,-1.01 -4.48,-2.57 -5.96,-4.64 -1.44,-2.03 -2.47,-4.6 -3.04,-7.62 -0.57,-2.94 -0.85,-6.42 -0.85,-10.37l0 -7.59c0,-3.11 0.5,-5.94 1.49,-8.42 0.99,-2.49 2.38,-4.61 4.14,-6.3 1.75,-1.68 3.81,-2.99 6.14,-3.88 2.32,-0.88 4.87,-1.33 7.58,-1.33z" />
|
||||
|
||||
<!-- Green paths (fil1: #9AD141) -->
|
||||
<path
|
||||
android:fillColor="#9AD141"
|
||||
android:pathData="M72.13 28.36c-1.86,0 -3.37,1.55 -3.37,3.47l0 44.7c0,1.92 1.51,3.47 3.37,3.47 1.86,0 3.36,-1.55 3.36,-3.47l0 -44.7c0,-1.92 -1.5,-3.47 -3.36,-3.47z" />
|
||||
<path
|
||||
android:fillColor="#9AD141"
|
||||
android:pathData="M62.69 37.55c-0.67,-1.86 -1.63,-3.49 -2.84,-4.84 -1.21,-1.36 -2.68,-2.43 -4.37,-3.2 -1.68,-0.76 -3.56,-1.15 -5.57,-1.15l-12.86 0 0 0c-1.78,0 -3.22,1.49 -3.22,3.32 0,1.83 1.44,3.32 3.22,3.32l12.86 0c2.22,0 3.9,0.81 5.13,2.46 1.28,1.7 1.92,3.84 1.92,6.38l0 29.32 -15.7 0c-1.09,0 -2.13,-0.21 -3.1,-0.62 -0.97,-0.41 -1.82,-0.99 -2.53,-1.72 -0.71,-0.74 -1.28,-1.6 -1.68,-2.57 -0.39,-0.96 -0.59,-2.02 -0.59,-3.14 0,-1.12 0.2,-2.2 0.6,-3.2 0.4,-0.99 0.96,-1.87 1.67,-2.61 0.72,-0.73 1.57,-1.33 2.55,-1.78 0.96,-0.43 2,-0.66 3.08,-0.66l7.38 0 0 0.01c1.8,0 3.27,-1.52 3.27,-3.38 0,-1.86 -1.47,-3.37 -3.27,-3.37l-7.38 0c-2.01,0 -3.93,0.4 -5.71,1.2 -1.77,0.79 -3.33,1.88 -4.63,3.22 -1.31,1.35 -2.35,2.95 -3.09,4.74 -0.73,1.8 -1.11,3.76 -1.11,5.83 0,2.07 0.38,4.03 1.11,5.83 0.74,1.79 1.78,3.38 3.09,4.74 1.31,1.35 2.87,2.42 4.64,3.18 1.77,0.76 3.69,1.14 5.7,1.14l17.78 0c1.31,0 2.42,-0.47 3.31,-1.38 0.89,-0.92 1.34,-2.07 1.34,-3.41l0 -31.67c0,-2.11 -0.34,-4.13 -1,-5.99z" />
|
||||
<path
|
||||
android:fillColor="#9AD141"
|
||||
android:pathData="M26.66 28.36l-9.83 0 0 -7.66 0 0c0,-1.08 0,-1.83 0,-1.83 0,-3.31 1.04,-6.76 3.05,-9.42 1.11,-1.47 2.8,-2.4 4.63,-2.77 1.27,-0.25 5.79,-0.34 7.59,-0.35l0 0c1.75,0 3.16,-1.42 3.16,-3.17 0,-1.75 -1.41,-3.16 -3.16,-3.16 -3.39,0.02 -9.41,0.24 -12.15,1.58 -3.98,1.94 -7.03,5.42 -8.56,9.56 -0.85,2.28 -1.27,4.73 -1.27,7.34l0 9.88 -6.84 0c-1.75,0 -3.17,1.42 -3.17,3.16 0,1.75 1.42,3.48 3.17,3.48l0 0 6.84 0 0 41.64c0,1.86 1.5,3.36 3.36,3.36 1.85,0 3.35,-1.5 3.35,-3.36l0 -41.64 9.83 0 0 0.01c1.75,0 3.17,-1.74 3.17,-3.48 0,-1.75 -1.42,-3.17 -3.17,-3.17z" />
|
||||
<path
|
||||
android:fillColor="#9AD141"
|
||||
android:pathData="M156.53 37.55c-0.67,-1.86 -1.63,-3.49 -2.84,-4.84 -1.21,-1.36 -2.68,-2.43 -4.37,-3.2 -1.68,-0.76 -3.56,-1.15 -5.57,-1.15l-12.86 0 0 0c-1.78,0 -3.21,1.49 -3.21,3.32 0,1.83 1.43,3.32 3.21,3.32l12.86 0c2.22,0 3.9,0.81 5.14,2.46 1.27,1.7 1.91,3.84 1.91,6.38l0 29.32 -15.7 0c-1.09,0 -2.13,-0.21 -3.1,-0.62 -0.97,-0.41 -1.82,-0.99 -2.53,-1.72 -0.71,-0.74 -1.27,-1.6 -1.67,-2.57 -0.4,-0.96 -0.6,-2.02 -0.6,-3.14 0,-1.12 0.2,-2.2 0.6,-3.2 0.4,-0.99 0.96,-1.87 1.67,-2.61 0.72,-0.73 1.58,-1.33 2.55,-1.78 0.96,-0.43 2,-0.66 3.08,-0.66l7.38 0 0 0.01c1.81,0 3.27,-1.52 3.27,-3.38 0,-1.86 -1.46,-3.37 -3.27,-3.37l-7.38 0c-2.01,0 -3.93,0.4 -5.71,1.2 -1.77,0.79 -3.33,1.88 -4.63,3.22 -1.31,1.35 -2.35,2.95 -3.08,4.74 -0.74,1.8 -1.12,3.76 -1.12,5.83 0,2.07 0.38,4.03 1.12,5.83 0.73,1.79 1.77,3.38 3.08,4.74 1.31,1.35 2.87,2.42 4.64,3.18 1.78,0.76 3.69,1.14 5.7,1.14l17.78 0c1.31,0 2.42,-0.47 3.31,-1.38 0.89,-0.92 1.34,-2.07 1.34,-3.41l0 -31.67c0,-2.11 -0.33,-4.13 -1,-5.99z" />
|
||||
<path
|
||||
android:fillColor="#9AD141"
|
||||
android:pathData="M112.72 54.91c-2.71,-2.17 -6.46,-3.62 -11.16,-4.29l-3.62 -0.49c-3.92,-0.51 -6.78,-1.31 -8.51,-2.38 -1.58,-0.98 -2.35,-2.65 -2.35,-5.09 0,-2.76 0.9,-4.65 2.73,-5.77 1.95,-1.19 4.4,-1.89 7.34,-1.89l15.72 0.01 0 0c1.78,0 3.22,-1.48 3.22,-3.32 0,-1.83 -1.44,-3.32 -3.22,-3.32l-15.72 -0.01c-2.21,0 -4.33,0.35 -6.36,0.85 -2.06,0.52 -3.88,1.36 -5.42,2.5 -1.56,1.15 -2.83,2.67 -3.78,4.51 -0.94,1.85 -1.43,4.09 -1.43,6.64 0,4.73 1.51,8.17 4.47,10.23 2.82,1.97 6.79,3.23 11.78,3.77l4.07 0.49c3.13,0.43 5.55,1.33 7.2,2.66 1.58,1.28 2.34,2.91 2.34,5 0,3.16 -0.98,5.27 -2.99,6.45 -2.15,1.26 -5.11,1.9 -8.81,1.9l-14.83 0 0 0c-1.78,0 -3.23,1.48 -3.23,3.32 0,1.83 1.45,3.32 3.23,3.32l14.83 0c2.71,0 5.21,-0.24 7.44,-0.7 2.29,-0.48 4.29,-1.32 5.93,-2.49 1.66,-1.19 2.98,-2.77 3.93,-4.69 0.94,-1.91 1.42,-4.33 1.42,-7.2 0,-4.39 -1.42,-7.75 -4.22,-10.01z" />
|
||||
</vector>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -1,9 +0,0 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<gradient
|
||||
android:angle="135"
|
||||
android:centerColor="#009688"
|
||||
android:endColor="#00695C"
|
||||
android:startColor="#4DB6AC"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_selected="true">
|
||||
<layer-list>
|
||||
<item android:gravity="center" android:width="10dp" android:height="10dp">
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="?attr/colorPrimary" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
</item>
|
||||
<item>
|
||||
<layer-list>
|
||||
<item android:gravity="center" android:width="8dp" android:height="8dp">
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="?attr/colorOutline" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
</item>
|
||||
</selector>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:liftOnScroll="true"
|
||||
app:liftOnScrollTargetViewId="@id/recyclerView">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:title="@string/accounts"
|
||||
app:titleTextAppearance="?attr/textAppearanceTitleLarge" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:clipToPadding="false"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/navHostFragment"
|
||||
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:defaultNavHost="true"
|
||||
app:navGraph="@navigation/login_nav"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/drawer_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:openDrawer="start">
|
||||
|
||||
<include
|
||||
android:id="@+id/app_bar_main"
|
||||
layout="@layout/app_bar_main"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<com.google.android.material.navigation.NavigationView
|
||||
android:id="@+id/nav_view"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start"
|
||||
android:fitsSystemWindows="true"
|
||||
app:headerLayout="@layout/nav_header_main"
|
||||
app:menu="@menu/activity_main_drawer" />
|
||||
</androidx.drawerlayout.widget.DrawerLayout>
|
||||
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/viewPager"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/bottomBar"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/bottomBar"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingBottom="40dp"
|
||||
android:paddingTop="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/dotsIndicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="16dp"
|
||||
android:minHeight="0dp"
|
||||
android:background="@null"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginBottom="24dp"
|
||||
app:tabBackground="@drawable/tab_indicator_selector"
|
||||
app:tabGravity="center"
|
||||
app:tabIndicatorHeight="0dp"
|
||||
app:tabMaxWidth="16dp"
|
||||
app:tabMinWidth="16dp"
|
||||
app:tabPaddingStart="3dp"
|
||||
app:tabPaddingEnd="3dp"
|
||||
app:tabPaddingTop="0dp"
|
||||
app:tabPaddingBottom="0dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnNext"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:text="@string/next"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnGetStarted"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:text="@string/get_started"
|
||||
android:textSize="16sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,34 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:theme="@style/Theme.BasedBank.AppBarOverlay">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="?attr/colorPrimary"
|
||||
app:popupTheme="@style/Theme.BasedBank.PopupOverlay" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<include layout="@layout/content_main" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fab"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginEnd="@dimen/fab_margin"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:srcCompat="@android:drawable/ic_dialog_email" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior"
|
||||
tools:showIn="@layout/app_bar_main">
|
||||
|
||||
<fragment
|
||||
android:id="@+id/nav_host_fragment_content_main"
|
||||
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:defaultNavHost="true"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:navGraph="@navigation/mobile_navigation" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingTop="48dp"
|
||||
android:paddingBottom="32dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/select_bank"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/select_bank_desc"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginBottom="32dp" />
|
||||
|
||||
<!-- MIB Card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardMib"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:strokeWidth="1dp"
|
||||
app:strokeColor="?attr/colorOutline">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="138dp"
|
||||
android:layout_height="40dp"
|
||||
android:src="@drawable/mib_faisanet_logo"
|
||||
android:contentDescription="@string/mib_name"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/mib_name"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/mib_desc"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,173 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingTop="48dp"
|
||||
android:paddingBottom="32dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="138dp"
|
||||
android:layout_height="40dp"
|
||||
android:src="@drawable/mib_faisanet_logo"
|
||||
android:contentDescription="@string/mib_name"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginBottom="32dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/sign_in"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/sign_in_desc"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginBottom="32dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/username"
|
||||
android:layout_marginBottom="16dp"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etUsername"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:imeOptions="actionNext"
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/password"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:endIconMode="password_toggle"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etPassword"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:imeOptions="actionNext"
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/otp_seed"
|
||||
android:layout_marginBottom="8dp"
|
||||
app:endIconMode="password_toggle"
|
||||
app:helperText="@string/otp_seed_hint"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etOtpSeed"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:imeOptions="actionDone"
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardOtp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:visibility="gone"
|
||||
app:cardBackgroundColor="?attr/colorSecondaryContainer"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Current OTP"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOtpCode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
android:letterSpacing="0.15"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/otpTimer"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
app:indicatorSize="32dp"
|
||||
app:trackThickness="3dp"
|
||||
app:indicatorColor="?attr/colorOnSecondaryContainer"
|
||||
app:trackColor="?attr/colorSecondaryContainer"
|
||||
android:indeterminate="false" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvError"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:textColor="?attr/colorError"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:visibility="gone"
|
||||
app:trackCornerRadius="4dp"
|
||||
android:indeterminate="true" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnLogin"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:text="@string/login"
|
||||
android:textSize="16sp"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".ui.gallery.GalleryFragment">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_gallery"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:textAlignment="center"
|
||||
android:textSize="20sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".ui.home.HomeFragment">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_home"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:textAlignment="center"
|
||||
android:textSize="20sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,102 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_horizontal"
|
||||
android:paddingHorizontal="32dp"
|
||||
android:paddingTop="64dp"
|
||||
android:paddingBottom="16dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="120dp"
|
||||
android:layout_height="120dp"
|
||||
android:layout_marginBottom="40dp"
|
||||
android:contentDescription="@string/app_name" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/description"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:gravity="center"
|
||||
android:lineSpacingMultiplier="1.4" />
|
||||
|
||||
<!-- Placeholder bank cards shown only on first slide -->
|
||||
<LinearLayout
|
||||
android:id="@+id/placeholderCards"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="40dp"
|
||||
android:weightSum="2"
|
||||
android:visibility="gone">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="80dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="8dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:strokeWidth="1dp"
|
||||
app:strokeColor="?attr/colorOutline">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/coming_soon"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="80dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:strokeWidth="1dp"
|
||||
app:strokeColor="?attr/colorOutline">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/coming_soon"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".ui.slideshow.SlideshowFragment">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_slideshow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:textAlignment="center"
|
||||
android:textSize="20sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,107 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="1dp"
|
||||
app:strokeColor="?attr/colorOutlineVariant">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAccountName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAccountNumber"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="10dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:background="@drawable/chip_background"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="?attr/colorOutlineVariant"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/available_balance"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvBalance"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAccountType"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="24dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvProfileName"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvProfileType"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="10dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:background="@drawable/chip_background"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -1,35 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/nav_header_height"
|
||||
android:background="@drawable/side_nav_bar"
|
||||
android:gravity="bottom"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="@dimen/activity_horizontal_margin"
|
||||
android:paddingTop="@dimen/activity_vertical_margin"
|
||||
android:paddingRight="@dimen/activity_horizontal_margin"
|
||||
android:paddingBottom="@dimen/activity_vertical_margin"
|
||||
android:theme="@style/ThemeOverlay.AppCompat.Dark">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/imageView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:contentDescription="@string/nav_header_desc"
|
||||
android:paddingTop="@dimen/nav_header_vertical_spacing"
|
||||
app:srcCompat="@mipmap/ic_launcher_round" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="@dimen/nav_header_vertical_spacing"
|
||||
android:text="@string/nav_header_title"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Body1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/nav_header_subtitle" />
|
||||
</LinearLayout>
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:showIn="navigation_view">
|
||||
|
||||
<group android:checkableBehavior="single">
|
||||
<item
|
||||
android:id="@+id/nav_home"
|
||||
android:icon="@drawable/ic_menu_camera"
|
||||
android:title="@string/menu_home" />
|
||||
<item
|
||||
android:id="@+id/nav_gallery"
|
||||
android:icon="@drawable/ic_menu_gallery"
|
||||
android:title="@string/menu_gallery" />
|
||||
<item
|
||||
android:id="@+id/nav_slideshow"
|
||||
android:icon="@drawable/ic_menu_slideshow"
|
||||
android:title="@string/menu_slideshow" />
|
||||
</group>
|
||||
</menu>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<item
|
||||
android:id="@+id/action_settings"
|
||||
android:orderInCategory="100"
|
||||
android:title="@string/action_settings"
|
||||
app:showAsAction="never" />
|
||||
</menu>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/login_nav"
|
||||
app:startDestination="@id/bankSelectionFragment">
|
||||
|
||||
<fragment
|
||||
android:id="@+id/bankSelectionFragment"
|
||||
android:name="sh.sar.basedbank.ui.login.BankSelectionFragment"
|
||||
android:label="Select Bank">
|
||||
|
||||
<action
|
||||
android:id="@+id/action_bankSelection_to_credentials"
|
||||
app:destination="@id/credentialsFragment"
|
||||
app:enterAnim="@anim/slide_in_right"
|
||||
app:exitAnim="@anim/slide_out_left"
|
||||
app:popEnterAnim="@anim/slide_in_left"
|
||||
app:popExitAnim="@anim/slide_out_right" />
|
||||
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/credentialsFragment"
|
||||
android:name="sh.sar.basedbank.ui.login.CredentialsFragment"
|
||||
android:label="Sign In" />
|
||||
|
||||
</navigation>
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/mobile_navigation"
|
||||
app:startDestination="@+id/nav_home">
|
||||
|
||||
<fragment
|
||||
android:id="@+id/nav_home"
|
||||
android:name="sh.sar.basedbank.ui.home.HomeFragment"
|
||||
android:label="@string/menu_home"
|
||||
tools:layout="@layout/fragment_home" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/nav_gallery"
|
||||
android:name="sh.sar.basedbank.ui.gallery.GalleryFragment"
|
||||
android:label="@string/menu_gallery"
|
||||
tools:layout="@layout/fragment_gallery" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/nav_slideshow"
|
||||
android:name="sh.sar.basedbank.ui.slideshow.SlideshowFragment"
|
||||
android:label="@string/menu_slideshow"
|
||||
tools:layout="@layout/fragment_slideshow" />
|
||||
</navigation>
|
||||
@@ -1,16 +1,8 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.BasedBank" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_200</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/black</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_200</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
<resources>
|
||||
<!-- Night mode inherits from base theme; DynamicColors handles the rest -->
|
||||
<style name="Theme.BasedBank" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">@color/seed_primary</item>
|
||||
<item name="colorSecondary">@color/seed_secondary</item>
|
||||
<item name="android:windowSoftInputMode">adjustResize</item>
|
||||
</style>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
<!-- Seed colors from MIB brand (blue + green) — used as M3 fallback on Android <12 -->
|
||||
<color name="seed_primary">#3F65AD</color>
|
||||
<color name="seed_secondary">#9AD141</color>
|
||||
</resources>
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
<resources>
|
||||
<string name="app_name">Based Bank</string>
|
||||
<string name="navigation_drawer_open">Open navigation drawer</string>
|
||||
<string name="navigation_drawer_close">Close navigation drawer</string>
|
||||
<string name="nav_header_title">Android Studio</string>
|
||||
<string name="nav_header_subtitle">android.studio@android.com</string>
|
||||
<string name="nav_header_desc">Navigation header</string>
|
||||
<string name="action_settings">Settings</string>
|
||||
<string name="app_name">BasedBank</string>
|
||||
|
||||
<string name="menu_home">Home</string>
|
||||
<string name="menu_gallery">Gallery</string>
|
||||
<string name="menu_slideshow">Slideshow</string>
|
||||
</resources>
|
||||
<!-- Onboarding -->
|
||||
<string name="onboarding_title_1">Your Banks, One App</string>
|
||||
<string name="onboarding_desc_1">BasedBank brings all your Maldivian bank accounts together in one place. Check balances, view accounts, and more — without switching between apps.</string>
|
||||
<string name="onboarding_title_2">More Banks Coming</string>
|
||||
<string name="onboarding_desc_2">Support for additional banks is on the way. Stay tuned as we expand coverage across the Maldives.</string>
|
||||
<string name="onboarding_title_3">Get Started</string>
|
||||
<string name="onboarding_desc_3">Add your bank credentials and start viewing your accounts. Your data stays on your device.</string>
|
||||
<string name="coming_soon">Coming Soon</string>
|
||||
<string name="next">Next</string>
|
||||
<string name="get_started">Get Started</string>
|
||||
|
||||
<!-- Login -->
|
||||
<string name="select_bank">Select Your Bank</string>
|
||||
<string name="select_bank_desc">Choose the bank you want to sign in to.</string>
|
||||
<string name="mib_name">Maldives Islamic Bank</string>
|
||||
<string name="mib_desc">Faisanet Mobile Banking</string>
|
||||
<string name="sign_in">Sign In</string>
|
||||
<string name="sign_in_desc">Enter your Maldives Islamic Bank credentials.</string>
|
||||
<string name="username">Username</string>
|
||||
<string name="password">Password</string>
|
||||
<string name="otp_seed">OTP Seed (TOTP Secret)</string>
|
||||
<string name="otp_seed_hint">The Base32 secret from your authenticator setup</string>
|
||||
<string name="login">Login</string>
|
||||
|
||||
<!-- Home -->
|
||||
<string name="accounts">Accounts</string>
|
||||
<string name="available_balance">Available Balance</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.BasedBank" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_500</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_700</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
<style name="Theme.BasedBank" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">@color/seed_primary</item>
|
||||
<item name="colorSecondary">@color/seed_secondary</item>
|
||||
<item name="android:windowSoftInputMode">adjustResize</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.BasedBank.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.BasedBank.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" />
|
||||
|
||||
<style name="Theme.BasedBank.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">faisanet.mib.com.mv</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
<svg width="60" height="60" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path fill="#FFF" d="M0 59.776h59.785V0H0z"/><path fill="#E21B23" d="M3.297 56.421h53.191V3.356H3.298z"/><path d="M37.421 6.708v34.059h-3.7V20.853L22.763 40.767H18.65l18.77-34.06zM18.517 51.073l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.03c-.613.313-1.228.626-1.88.626-.65 0-1.265-.313-1.879-.626-.553-.283-1.108-.564-1.624-.564-.514 0-1.069.28-1.62.564-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.068.28-1.62.564c-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.069.28-1.622.564c-.614.313-1.228.626-1.879.626-.6 0-1.167-.265-1.73-.55v-1.446zm0-2.816l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.028c-.613.315-1.228.627-1.88.627-.65 0-1.265-.312-1.879-.627-.553-.281-1.108-.562-1.624-.562-.514 0-1.069.28-1.62.562-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.068.28-1.62.562c-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.069.28-1.622.562c-.614.315-1.228.627-1.879.627-.6 0-1.167-.264-1.73-.55v-1.445zm12.428-6.042h12.41v3.969h-12.41v-.001H18.531l-2.1-3.968h14.514z" fill="#FFF"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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": "<profile ID>",
|
||||
"customerProfileId": "<customer profile ID>",
|
||||
"annexId": "<annex ID>",
|
||||
"customerId": "<customer ID>",
|
||||
"name": "<profile display name>",
|
||||
"cifType": "Individual",
|
||||
"customerImage": "<image hash>",
|
||||
"profileType": "0",
|
||||
"color": "<hex color>"
|
||||
},
|
||||
{
|
||||
"profileId": "<profile ID>",
|
||||
"customerProfileId": "<customer profile ID>",
|
||||
"annexId": "<annex ID>",
|
||||
"customerId": "<customer ID>",
|
||||
"name": "<business name / owner name>",
|
||||
"cifType": "Sole Propr",
|
||||
"customerImage": "<image hash>",
|
||||
"profileType": "1",
|
||||
"color": "<hex 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": "<session xxid>",
|
||||
"data": {
|
||||
"profileType": "<profileType from operatingProfiles>",
|
||||
"profileId": "<profileId from operatingProfiles>",
|
||||
"nonce": "<computed nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random 20-bit int>",
|
||||
"routePath": "P47",
|
||||
"xxid": "<session 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": "<CIF number>",
|
||||
"accountNumber": "<full account number>",
|
||||
"accountBriefName": "<short label, e.g. 'SAR MVR - Savings'>",
|
||||
"template": "<display template ID>",
|
||||
"currencyCode": "<ISO 4217 numeric code>",
|
||||
"currencyName": "<ISO 4217 alpha code>",
|
||||
"accountTypeName": "<account type label>",
|
||||
"transfer": "Y",
|
||||
"branchName": "<branch name>",
|
||||
"availableBalance": "<decimal string>",
|
||||
"currentBalance": "<decimal string>",
|
||||
"blockedAmount": "<decimal string, may be negative>",
|
||||
"settlementBalance": "<decimal string>",
|
||||
"mvrBalance": "<MVR equivalent as decimal string>",
|
||||
"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": "<number of accounts>",
|
||||
"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.
|
||||
@@ -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=<function_code>&data=<urlencode(blowfish_ecb_base64_ciphertext)>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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": "<G^A mod P as decimal string>",
|
||||
"appId": "IOS17.2-<random 15-char string>",
|
||||
"routePath": "S40",
|
||||
"sodium": "<random 20-bit integer as string>",
|
||||
"xxid": "<random 40-bit integer as string>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response payload** (encrypted with DEFAULT_KEY):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"responseCode": "1",
|
||||
"reasonCode": "201",
|
||||
"reasonText": "Key generated successfully.",
|
||||
"smod": "<server DH public key as decimal string>",
|
||||
"nonceGenerator": "<instruction string for nonce computation>",
|
||||
"xxid": "<session token>",
|
||||
"sodium": "<server random>",
|
||||
"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": "<function_code>",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"nonce": "<computed from nonceGenerator>",
|
||||
"appId": "<same appId>",
|
||||
"sodium": "<random 20-bit>",
|
||||
"routePath": "<route constant>",
|
||||
"xxid": "<session 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=<key2>&sfunc=i&data=<encrypted>`. 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=<session_xxid>&sfunc=n&data=<encrypted>`. The `xxid` also appears inside the encrypted
|
||||
payload. Field order matters — `xxid` must come before `sfunc` and `data`.
|
||||
|
||||
@@ -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": "<function code>",
|
||||
"xxid": "<session token>",
|
||||
"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=<value>&data=<url-encoded-ciphertext>`
|
||||
|
||||
```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=<base64 of Blowfish(DEFAULT_KEY, {"sfunc":"r","data":{"cmod":"...","appId":"...","routePath":"S40","sodium":"...","xxid":"..."}})>
|
||||
```
|
||||
|
||||
Response (decrypted with DEFAULT_KEY):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"smod": "<large decimal integer — server DH public key>",
|
||||
"nonceGenerator": "<instruction string, e.g. 'M26 C16 C4 C5 M64 ...'>",
|
||||
"xxid": "<session token>",
|
||||
"sodium": "<server random hex string>",
|
||||
"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 |
|
||||
@@ -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": "<G^A_VALUE mod P_VALUE as decimal string>",
|
||||
"appId": "IOS17.2-<15 random chars>",
|
||||
"routePath": "S40",
|
||||
"sodium": "<random 20-bit int>",
|
||||
"xxid": "<random 40-bit int>"
|
||||
}
|
||||
```
|
||||
|
||||
**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": "<server DH public key>",
|
||||
"nonceGenerator": "<instruction string>",
|
||||
"xxid": "<session token — carry for all subsequent calls>",
|
||||
"sodium": "<server random>",
|
||||
"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": "<session xxid>",
|
||||
"data": {
|
||||
"uname": "<username>",
|
||||
"nonce": "<computed from nonceGenerator>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random 20-bit int>",
|
||||
"routePath": "A44",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "108",
|
||||
"reasonText": "Auth type retrieved!",
|
||||
"data": [
|
||||
{
|
||||
"loginType": "1",
|
||||
"userSalt": "<server salt for password hashing>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [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": "<session xxid>",
|
||||
"data": {
|
||||
"uname": "<username>",
|
||||
"pgf03": "<salted password hash — see below>",
|
||||
"clientSalt": "<random 32-char string>",
|
||||
"nonce": "<nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "C41",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "201",
|
||||
"reasonText": "Registration Initialization Successfully.",
|
||||
"primaryOTPType": "3",
|
||||
"roleName": "Consumer Premium",
|
||||
"otpTypes": [2, 3],
|
||||
"fullName": "<user's full name>",
|
||||
"lastLoginTime": "<datetime>",
|
||||
"customerImgHash": "<hash>"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [3] OTP Verification (Registration) — `sfunc=n`, `routePath: C42`
|
||||
|
||||
**Key**: `session_key_1`
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"otp": "<6-digit OTP>",
|
||||
"uname": "<username>",
|
||||
"otpType": "3",
|
||||
"nonce": "<nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "C42",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "101",
|
||||
"reasonText": "registration success",
|
||||
"data": [
|
||||
{
|
||||
"appId": "<appId>",
|
||||
"createdDate": "<datetime>",
|
||||
"key1": "<device credential 1 — store securely>",
|
||||
"key2": "<device credential 2 — store securely>",
|
||||
"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": "<key2 from registration>",
|
||||
"data": {
|
||||
"cmod": "<G^A_VALUE mod P_VALUE>",
|
||||
"appId": "<appId>",
|
||||
"routePath": "S40",
|
||||
"sodium": "<random 20-bit int>",
|
||||
"xxid": "<random 40-bit int>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (decrypted with `key1`):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "201",
|
||||
"reasonText": "Key generated successfully.",
|
||||
"smod": "<new server DH public key>",
|
||||
"nonceGenerator": "<new instruction string>",
|
||||
"xxid": "<new session token>",
|
||||
"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": "<session xxid>",
|
||||
"data": {
|
||||
"uname": "<username>",
|
||||
"pgf03": "<salted password hash>",
|
||||
"clientSalt": "<random 32-char string>",
|
||||
"pmodTime": 0,
|
||||
"requireBankData": 1,
|
||||
"nonce": "<nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "A41",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "104",
|
||||
"reasonText": "Initialization Successful",
|
||||
"primaryOTPType": "3",
|
||||
"roleName": "Consumer Premium",
|
||||
"otpTypes": [2, 3],
|
||||
"email": "<masked email>",
|
||||
"uuid": "<uuid1>",
|
||||
"uuid2": "<uuid2>",
|
||||
"xxid": "<xxid>"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [7] Get Profile Image — `sfunc=n`, `routePath: P41`
|
||||
|
||||
**Key**: `session_key_2`
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"imageHash": "<customerImgHash from step 2/6>",
|
||||
"nonce": "<nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "P41",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "201",
|
||||
"reasonText": "Image Found",
|
||||
"profileImage": "<base64 JPEG>"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [8] OTP Verification (Login) — `sfunc=n`, `routePath: A42`
|
||||
|
||||
**Key**: `session_key_2`
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"otp": "<6-digit OTP>",
|
||||
"uname": "<username>",
|
||||
"otpType": "3",
|
||||
"nonce": "<nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "A42",
|
||||
"xxid": "<session 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 |
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Faisanet MIB API decryption tool.
|
||||
Usage:
|
||||
./decrypt.py <encrypted_base64>
|
||||
./decrypt.py <encrypted_base64> --key <blowfish_key>
|
||||
./decrypt.py <encrypted_base64> --smod <server_dh_public_key>
|
||||
"""
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW 2021 (64-Bit) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100px" height="40px" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
|
||||
viewBox="0 0 276 80"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil0 {fill:#3F65AD;fill-rule:nonzero}
|
||||
.fil1 {fill:#9AD141;fill-rule:nonzero}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<g id="_2901355026512">
|
||||
<path class="fil0" d="M272.33 28.36l-12.05 0 0 -6.16c0,-1 -0.36,-1.87 -1.08,-2.59 -0.73,-0.73 -1.61,-1.09 -2.63,-1.08 -0.95,0 -1.87,0.41 -2.52,1.11 -0.67,0.71 -1.01,1.58 -1.01,2.56l0 6.16 -4.07 0c-4.95,0 -4.66,6.64 0,6.64l4.07 0 0 27.8c0,5.47 1.19,9.74 3.53,12.67 2.41,3.01 6.48,4.53 12.11,4.53l3.65 0c1.72,0 3.16,-1.2 3.54,-2.81l0 -1.63c-0.38,-1.61 -1.82,-2.81 -3.54,-2.81l-3.65 0c-3.2,0 -5.41,-0.79 -6.56,-2.35 -1.22,-1.65 -1.84,-4.22 -1.84,-7.6l0 -27.8 12.05 0c4.74,0 4.74,-6.64 0,-6.64z"/>
|
||||
<path class="fil0" d="M169.85 76.38l0 -41.07 15.31 0c0.74,0 1.47,0.08 2.18,0.27 1.63,0.46 3.04,1.32 4.16,2.55 0.82,0.91 1.49,2.02 1.99,3.29 0.5,1.27 0.76,2.7 0.76,4.24l0 30.72c0,2 1.62,3.62 3.62,3.62l0 0c2,0 3.63,-1.62 3.63,-3.62l0 -30.72c0,-2.72 -0.44,-5.17 -1.3,-7.3 -0.86,-2.14 -2.07,-3.98 -3.59,-5.47 -1.53,-1.49 -3.3,-2.63 -5.27,-3.39 -1.97,-0.76 -4.04,-1.14 -6.17,-1.14l-17.43 0c-1.54,0 -2.8,0.54 -3.75,1.61 -0.92,1.04 -1.38,2.26 -1.38,3.62l0 42.79c0,2 1.62,3.62 3.62,3.62l0 0c2,0 3.62,-1.62 3.62,-3.62z"/>
|
||||
<path class="fil0" d="M225.3 28.36c15.78,0 18.49,6.01 19.17,19.96 0.27,5.45 -0.89,8.87 -5.93,9.27l-17.03 0c-1.8,0 -3.27,-1.51 -3.27,-3.37 0,-1.86 1.47,-3.37 3.27,-3.37l0.77 0 12.95 0 2.56 0c0,-9.79 0.21,-16.11 -12.49,-15.54 -3.89,0 -6.8,1.13 -8.92,3.46 -2.11,2.32 -3.18,5.53 -3.18,9.52l0.03 9.51c0.09,2.35 0.32,4.45 0.68,6.25 0.39,2 1.04,3.69 1.93,5.03 0.87,1.29 2.03,2.28 3.47,2.94 1.47,0.68 3.39,1.02 5.69,1.02l2 0 4.51 0 3.48 0c1.77,0.01 3.21,1.54 3.21,3.38 0,1.83 -1.44,3.58 -3.21,3.58l-3.48 0 -4.51 0 -2.09 0c-3.63,0 -6.69,-0.5 -9.11,-1.49 -2.48,-1.01 -4.48,-2.57 -5.96,-4.64 -1.44,-2.03 -2.47,-4.6 -3.04,-7.62 -0.57,-2.94 -0.85,-6.42 -0.85,-10.37l0 -7.59c0,-3.11 0.5,-5.94 1.49,-8.42 0.99,-2.49 2.38,-4.61 4.14,-6.3 1.75,-1.68 3.81,-2.99 6.14,-3.88 2.32,-0.88 4.87,-1.33 7.58,-1.33z"/>
|
||||
<path class="fil1" d="M72.13 28.36c-1.86,0 -3.37,1.55 -3.37,3.47l0 44.7c0,1.92 1.51,3.47 3.37,3.47 1.86,0 3.36,-1.55 3.36,-3.47l0 -44.7c0,-1.92 -1.5,-3.47 -3.36,-3.47z"/>
|
||||
<path class="fil1" d="M62.69 37.55c-0.67,-1.86 -1.63,-3.49 -2.84,-4.84 -1.21,-1.36 -2.68,-2.43 -4.37,-3.2 -1.68,-0.76 -3.56,-1.15 -5.57,-1.15l-12.86 0 0 0c-1.78,0 -3.22,1.49 -3.22,3.32 0,1.83 1.44,3.32 3.22,3.32l12.86 0c2.22,0 3.9,0.81 5.13,2.46 1.28,1.7 1.92,3.84 1.92,6.38l0 29.32 -15.7 0c-1.09,0 -2.13,-0.21 -3.1,-0.62 -0.97,-0.41 -1.82,-0.99 -2.53,-1.72 -0.71,-0.74 -1.28,-1.6 -1.68,-2.57 -0.39,-0.96 -0.59,-2.02 -0.59,-3.14 0,-1.12 0.2,-2.2 0.6,-3.2 0.4,-0.99 0.96,-1.87 1.67,-2.61 0.72,-0.73 1.57,-1.33 2.55,-1.78 0.96,-0.43 2,-0.66 3.08,-0.66l7.38 0 0 0.01c1.8,0 3.27,-1.52 3.27,-3.38 0,-1.86 -1.47,-3.37 -3.27,-3.37l-7.38 0c-2.01,0 -3.93,0.4 -5.71,1.2 -1.77,0.79 -3.33,1.88 -4.63,3.22 -1.31,1.35 -2.35,2.95 -3.09,4.74 -0.73,1.8 -1.11,3.76 -1.11,5.83 0,2.07 0.38,4.03 1.11,5.83 0.74,1.79 1.78,3.38 3.09,4.74 1.31,1.35 2.87,2.42 4.64,3.18 1.77,0.76 3.69,1.14 5.7,1.14l17.78 0c1.31,0 2.42,-0.47 3.31,-1.38 0.89,-0.92 1.34,-2.07 1.34,-3.41l0 -31.67c0,-2.11 -0.34,-4.13 -1,-5.99z"/>
|
||||
<path class="fil1" d="M26.66 28.36l-9.83 0 0 -7.66 0 0c0,-1.08 0,-1.83 0,-1.83 0,-3.31 1.04,-6.76 3.05,-9.42 1.11,-1.47 2.8,-2.4 4.63,-2.77 1.27,-0.25 5.79,-0.34 7.59,-0.35l0 0c1.75,0 3.16,-1.42 3.16,-3.17 0,-1.75 -1.41,-3.16 -3.16,-3.16 -3.39,0.02 -9.41,0.24 -12.15,1.58 -3.98,1.94 -7.03,5.42 -8.56,9.56 -0.85,2.28 -1.27,4.73 -1.27,7.34l0 9.88 -6.84 0c-1.75,0 -3.17,1.42 -3.17,3.16 0,1.75 1.42,3.48 3.17,3.48l0 0 6.84 0 0 41.64c0,1.86 1.5,3.36 3.36,3.36 1.85,0 3.35,-1.5 3.35,-3.36l0 -41.64 9.83 0 0 0.01c1.75,0 3.17,-1.74 3.17,-3.48 0,-1.75 -1.42,-3.17 -3.17,-3.17z"/>
|
||||
<path class="fil1" d="M156.53 37.55c-0.67,-1.86 -1.63,-3.49 -2.84,-4.84 -1.21,-1.36 -2.68,-2.43 -4.37,-3.2 -1.68,-0.76 -3.56,-1.15 -5.57,-1.15l-12.86 0 0 0c-1.78,0 -3.21,1.49 -3.21,3.32 0,1.83 1.43,3.32 3.21,3.32l12.86 0c2.22,0 3.9,0.81 5.14,2.46 1.27,1.7 1.91,3.84 1.91,6.38l0 29.32 -15.7 0c-1.09,0 -2.13,-0.21 -3.1,-0.62 -0.97,-0.41 -1.82,-0.99 -2.53,-1.72 -0.71,-0.74 -1.27,-1.6 -1.67,-2.57 -0.4,-0.96 -0.6,-2.02 -0.6,-3.14 0,-1.12 0.2,-2.2 0.6,-3.2 0.4,-0.99 0.96,-1.87 1.67,-2.61 0.72,-0.73 1.58,-1.33 2.55,-1.78 0.96,-0.43 2,-0.66 3.08,-0.66l7.38 0 0 0.01c1.81,0 3.27,-1.52 3.27,-3.38 0,-1.86 -1.46,-3.37 -3.27,-3.37l-7.38 0c-2.01,0 -3.93,0.4 -5.71,1.2 -1.77,0.79 -3.33,1.88 -4.63,3.22 -1.31,1.35 -2.35,2.95 -3.08,4.74 -0.74,1.8 -1.12,3.76 -1.12,5.83 0,2.07 0.38,4.03 1.12,5.83 0.73,1.79 1.77,3.38 3.08,4.74 1.31,1.35 2.87,2.42 4.64,3.18 1.78,0.76 3.69,1.14 5.7,1.14l17.78 0c1.31,0 2.42,-0.47 3.31,-1.38 0.89,-0.92 1.34,-2.07 1.34,-3.41l0 -31.67c0,-2.11 -0.33,-4.13 -1,-5.99z"/>
|
||||
<path class="fil1" d="M112.72 54.91c-2.71,-2.17 -6.46,-3.62 -11.16,-4.29l-3.62 -0.49c-3.92,-0.51 -6.78,-1.31 -8.51,-2.38 -1.58,-0.98 -2.35,-2.65 -2.35,-5.09 0,-2.76 0.9,-4.65 2.73,-5.77 1.95,-1.19 4.4,-1.89 7.34,-1.89l15.72 0.01 0 0c1.78,0 3.22,-1.48 3.22,-3.32 0,-1.83 -1.44,-3.32 -3.22,-3.32l-15.72 -0.01c-2.21,0 -4.33,0.35 -6.36,0.85 -2.06,0.52 -3.88,1.36 -5.42,2.5 -1.56,1.15 -2.83,2.67 -3.78,4.51 -0.94,1.85 -1.43,4.09 -1.43,6.64 0,4.73 1.51,8.17 4.47,10.23 2.82,1.97 6.79,3.23 11.78,3.77l4.07 0.49c3.13,0.43 5.55,1.33 7.2,2.66 1.58,1.28 2.34,2.91 2.34,5 0,3.16 -0.98,5.27 -2.99,6.45 -2.15,1.26 -5.11,1.9 -8.81,1.9l-14.83 0 0 0c-1.78,0 -3.23,1.48 -3.23,3.32 0,1.83 1.45,3.32 3.23,3.32l14.83 0c2.71,0 5.21,-0.24 7.44,-0.7 2.29,-0.48 4.29,-1.32 5.93,-2.49 1.66,-1.19 2.98,-2.77 3.93,-4.69 0.94,-1.91 1.42,-4.33 1.42,-7.2 0,-4.39 -1.42,-7.75 -4.22,-10.01z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
Reference in New Issue
Block a user