working mib login and list accounts

This commit is contained in:
2026-05-12 04:19:52 +05:00
parent 31c8a5500d
commit 076a58359a
73 changed files with 3076 additions and 550 deletions
+3
View File
@@ -13,3 +13,6 @@
.externalNativeBuild
.cxx
local.properties
docs/mibapi/tmp
docs/bmlapi/tmp
tmp
+18
View File
@@ -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>
+13
View File
@@ -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>
+1
View File
@@ -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>
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
+22 -4
View File
@@ -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
}
}
+5
View File
@@ -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>
+5
View File
@@ -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>
+5
View File
@@ -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>
+33
View File
@@ -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>
-25
View File
@@ -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>
-34
View File
@@ -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>
-20
View File
@@ -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>
-22
View File
@@ -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>
+107
View File
@@ -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>
-9
View File
@@ -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>
+27
View File
@@ -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>
+7 -15
View File
@@ -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>
+4 -8
View File
@@ -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>
+29 -11
View File
@@ -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>
+5 -23
View File
@@ -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>
+2
View File
@@ -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

+172
View File
@@ -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 03 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.
+345
View File
@@ -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 199 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 17 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` (17):
- 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 ~1M16M, approximately 2324 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`.
+180
View File
@@ -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 |
+449
View File
@@ -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 |
+80
View File
@@ -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()
+29
View File
@@ -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