forked from shihaam/thijooree
working mib login and list accounts
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user