add app lock secruity and support for saving mib credentials

This commit is contained in:
2026-05-12 09:53:43 +05:00
parent 7209e9dca0
commit 81a2be150f
17 changed files with 1259 additions and 35 deletions

View File

@@ -61,6 +61,9 @@ dependencies {
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// Biometric authentication
implementation("androidx.biometric:biometric:1.1.0")
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
@@ -29,6 +30,11 @@
</intent-filter>
</activity>
<activity
android:name=".LockActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.onboarding.OnboardingActivity"
android:exported="false" />

View File

@@ -0,0 +1,168 @@
package sh.sar.basedbank
import android.content.Intent
import android.os.Bundle
import android.util.Base64
import android.view.View
import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import sh.sar.basedbank.databinding.ActivityLockBinding
import sh.sar.basedbank.ui.login.LoginActivity
import java.security.MessageDigest
class LockActivity : AppCompatActivity() {
private lateinit var binding: ActivityLockBinding
private val pinDigits = mutableListOf<Int>()
private lateinit var method: String
private lateinit var salt: String
private lateinit var storedHash: String
private var biometricsEnabled = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLockBinding.inflate(layoutInflater)
setContentView(binding.root)
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
method = prefs.getString("security_method", "pin") ?: "pin"
salt = prefs.getString("security_salt", "") ?: ""
storedHash = prefs.getString("security_hash", "") ?: ""
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
if (method == "pin") {
binding.viewPin.visibility = View.VISIBLE
buildNumpad()
updateDots()
if (biometricsEnabled) binding.btnLockBiometricFromPin.visibility = View.VISIBLE
binding.btnLockBiometricFromPin.setOnClickListener { triggerBiometric() }
} else {
binding.viewPattern.visibility = View.VISIBLE
if (biometricsEnabled) binding.btnLockBiometricFromPattern.visibility = View.VISIBLE
binding.btnLockBiometricFromPattern.setOnClickListener { triggerBiometric() }
binding.lockPatternView.onPatternComplete = { pattern -> verifyPattern(pattern) }
}
if (biometricsEnabled) triggerBiometric()
}
private fun buildNumpad() {
val rows = listOf(
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
listOf("", "0", "")
)
rows.forEach { keys ->
val row = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f
)
}
keys.forEach { key ->
val style = if (key == "")
com.google.android.material.R.attr.materialButtonStyle
else
com.google.android.material.R.attr.materialButtonOutlinedStyle
val btn = MaterialButton(this, null, style).apply {
text = key
textSize = 20f
insetTop = 0; insetBottom = 0
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)
.also { it.setMargins(4, 4, 4, 4) }
}
btn.setOnClickListener { handleKey(key) }
row.addView(btn)
}
binding.lockNumpadContainer.addView(row)
}
}
private fun handleKey(key: String) {
when (key) {
"" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
"" -> if (pinDigits.size >= 4) verifyPin()
else -> if (pinDigits.size < 8) { pinDigits.add(key.toInt()); updateDots() }
}
}
private fun updateDots() {
val n = pinDigits.size
binding.tvLockPinDots.text = "".repeat(n) + "".repeat(maxOf(4 - n, 0))
}
private fun verifyPin() {
val entered = pinDigits.joinToString("")
if (verify(entered)) {
proceed()
} else {
binding.tvLockPinDots.text = getString(R.string.unlock_failed)
pinDigits.clear()
binding.root.postDelayed({ updateDots() }, 1200)
}
}
private fun verifyPattern(pattern: List<Int>) {
if (pattern.size < 4) {
binding.lockPatternView.showError()
binding.tvPatternHint.text = getString(R.string.pattern_min_dots)
return
}
if (verify(pattern.joinToString(""))) {
proceed()
} else {
binding.lockPatternView.showError()
binding.tvPatternHint.text = getString(R.string.unlock_failed)
binding.root.postDelayed({ binding.tvPatternHint.text = "" }, 1500)
}
}
private fun verify(input: String): Boolean {
val hash = sha256(salt + input)
return hash == storedHash
}
private fun triggerBiometric() {
val canAuth = BiometricManager.from(this)
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)
if (canAuth != BiometricManager.BIOMETRIC_SUCCESS) return
val prompt = BiometricPrompt(
this,
ContextCompat.getMainExecutor(this),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
proceed()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
// User cancelled or error — fall back to PIN/pattern (already visible)
}
}
)
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle(getString(R.string.unlock_app))
.setSubtitle(getString(R.string.biometric_prompt_subtitle))
.setNegativeButtonText(getString(R.string.biometric_negative_btn))
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_WEAK)
.build()
prompt.authenticate(info)
}
private fun proceed() {
startActivity(Intent(this, LoginActivity::class.java))
finish()
}
private fun sha256(input: String) = MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
override fun onBackPressed() {
// Lock screen cannot be dismissed
moveTaskToBack(true)
}
}

View File

@@ -12,7 +12,12 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val onboardingDone = prefs.getBoolean("onboarding_done", false)
val target = if (onboardingDone) LoginActivity::class.java else OnboardingActivity::class.java
val securitySet = prefs.getString("security_method", null) != null
val target = when {
!onboardingDone -> OnboardingActivity::class.java
securitySet -> LockActivity::class.java
else -> LoginActivity::class.java
}
startActivity(Intent(this, target))
finish()
}

View File

@@ -35,9 +35,10 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
* Full login flow. Automatically handles first-time device registration
* vs. subsequent logins using stored key1/key2.
*
* @param passwordHash SHA-256(password) uppercase hex — use [hashPassword] to compute.
* Returns list of accounts from all profiles on success.
*/
fun login(username: String, password: String, otpSeed: String): List<MibAccount> {
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
val appId = getOrCreateAppId()
Log.d(TAG, "login: appId=$appId")
val key1 = prefs.getString("mib_key1_$username", null)
@@ -46,17 +47,17 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
return if (key1 != null && key2 != null) {
Log.d(TAG, "login: taking regular login path")
regularLogin(username, password, appId, key1, key2)
regularLogin(username, passwordHash, appId, key1, key2)
} else {
Log.d(TAG, "login: taking first-time registration path")
firstTimeRegistration(username, password, otpSeed, appId)
firstTimeRegistration(username, passwordHash, otpSeed, appId)
}
}
// ─── First-time registration ──────────────────────────────────────────────
private fun firstTimeRegistration(
username: String, password: String, otpSeed: String, appId: String
username: String, passwordHash: 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")
@@ -67,9 +68,9 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
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'")
Log.d(TAG, "[reg] username='$username' userSalt='$userSalt'")
val clientSalt = randomAlpha(32)
val pgf03 = computePgf03(password, userSalt, clientSalt)
val pgf03 = computePgf03(passwordHash, userSalt, clientSalt)
Log.d(TAG, "[reg] pgf03=$pgf03")
val regInitPayload = baseData(session1, "C41").apply {
put("uname", username)
@@ -102,13 +103,13 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
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)
return regularLogin(username, passwordHash, appId, key1, key2)
}
// ─── Regular login ────────────────────────────────────────────────────────
private fun regularLogin(
username: String, password: String,
username: String, passwordHash: String,
appId: String, key1: String, key2: String
): List<MibAccount> {
Log.d(TAG, "[login] step 4: key exchange (sfunc=i)")
@@ -121,7 +122,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
Log.d(TAG, "[login] step 6: login init (A41)")
val clientSalt = randomAlpha(32)
val pgf03 = computePgf03(password, userSalt, clientSalt)
val pgf03 = computePgf03(passwordHash, userSalt, clientSalt)
Log.d(TAG, "[login] pgf03 length=${pgf03.length}")
val loginPayload = baseData(session2, "A41").apply {
put("uname", username)
@@ -273,16 +274,24 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
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)
/** @param h1 SHA-256(password) uppercase hex, as returned by [hashPassword] */
private fun computePgf03(h1: String, userSalt: String, clientSalt: String): String {
val h2 = sha256Upper(h1 + userSalt)
return sha256Upper(clientSalt + h2)
}
companion object {
/** Returns SHA-256(password) as uppercase hex. Store this instead of the raw password. */
fun hashPassword(password: String): String =
MessageDigest.getInstance("SHA-256")
.digest(password.toByteArray())
.joinToString("") { "%02X".format(it) }
}
private fun sha256Upper(input: String) = MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray())
.joinToString("") { "%02X".format(it) }
private fun generateOtp(seed: String): String = Totp.generate(seed)
private fun getOrCreateAppId(): String {

View File

@@ -18,6 +18,7 @@ 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.util.CredentialStore
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
import sh.sar.basedbank.ui.home.HomeActivity
@@ -98,6 +99,7 @@ class CredentialsFragment : Fragment() {
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
val passwordHash = MibLoginFlow.hashPassword(password)
val prefs = requireContext().getSharedPreferences("mib_prefs", android.content.Context.MODE_PRIVATE)
val flow = MibLoginFlow(prefs)
@@ -105,9 +107,10 @@ class CredentialsFragment : Fragment() {
try {
Log.d(TAG, "Starting login flow on IO dispatcher")
val accounts = withContext(Dispatchers.IO) {
flow.login(username, password, otpSeed)
flow.login(username, passwordHash, otpSeed)
}
Log.d(TAG, "Login succeeded, got ${accounts.size} accounts")
CredentialStore(requireContext()).saveMibCredentials(username, passwordHash, otpSeed)
(requireActivity().application as BasedBankApp).accounts = accounts
startActivity(Intent(requireContext(), HomeActivity::class.java))
requireActivity().finish()

View File

@@ -1,10 +1,19 @@
package sh.sar.basedbank.ui.login
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.fragment.NavHostFragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.databinding.ActivityLoginBinding
import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.util.CredentialStore
class LoginActivity : AppCompatActivity() {
@@ -14,5 +23,31 @@ class LoginActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
val creds = CredentialStore(this).loadMibCredentials()
if (creds != null) {
binding.navHostFragment.visibility = View.GONE
binding.autoLoginGroup.visibility = View.VISIBLE
autoLogin(creds)
}
}
private fun autoLogin(creds: CredentialStore.MibCredentials) {
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
val flow = MibLoginFlow(prefs)
lifecycleScope.launch {
try {
val accounts = withContext(Dispatchers.IO) {
flow.login(creds.username, creds.passwordHash, creds.otpSeed)
}
(application as BasedBankApp).accounts = accounts
startActivity(Intent(this@LoginActivity, HomeActivity::class.java))
finish()
} catch (e: Exception) {
// Auto-login failed — fall back to manual login form
binding.autoLoginGroup.visibility = View.GONE
binding.navHostFragment.visibility = View.VISIBLE
}
}
}
}

View File

@@ -1,6 +1,7 @@
package sh.sar.basedbank.ui.onboarding
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
@@ -12,22 +13,24 @@ import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ActivityOnboardingBinding
import sh.sar.basedbank.ui.login.LoginActivity
class OnboardingActivity : AppCompatActivity() {
class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
private lateinit var binding: ActivityOnboardingBinding
private lateinit var prefs: SharedPreferences
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityOnboardingBinding.inflate(layoutInflater)
setContentView(binding.root)
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val adapter = OnboardingPagerAdapter(this)
binding.viewPager.adapter = adapter
TabLayoutMediator(binding.dotsIndicator, binding.viewPager) { _, _ -> }.attach()
// Pre-select the chip for the saved language without triggering the listener
val savedLang = getSharedPreferences("prefs", MODE_PRIVATE).getString("language", null)
// Pre-select language chip without triggering the listener
val savedLang = prefs.getString("language", null)
binding.languageChipGroup.setOnCheckedStateChangeListener(null)
when (savedLang) {
"en" -> binding.chipEnglish.isChecked = true
@@ -35,18 +38,24 @@ class OnboardingActivity : AppCompatActivity() {
}
binding.languageChipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
if (checkedIds.isNotEmpty()) {
val lang = if (checkedIds[0] == R.id.chipEnglish) "en" else "dv"
selectLanguage(lang)
selectLanguage(if (checkedIds[0] == R.id.chipEnglish) "en" else "dv")
}
}
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
binding.languageChipGroup.visibility = if (position == 0) View.VISIBLE else View.GONE
// Block forward swipe on slide 1 until security is set up
if (position == 1) {
binding.viewPager.isUserInputEnabled =
prefs.getString("security_method", null) != null
} else {
binding.viewPager.isUserInputEnabled = true
}
updateButtons(position, adapter.itemCount)
}
})
// Show chips and set initial button state for page 0
binding.languageChipGroup.visibility = View.VISIBLE
updateButtons(0, adapter.itemCount)
@@ -56,26 +65,41 @@ class OnboardingActivity : AppCompatActivity() {
}
binding.btnGetStarted.setOnClickListener {
getSharedPreferences("prefs", MODE_PRIVATE)
.edit().putBoolean("onboarding_done", true).apply()
prefs.edit().putBoolean("onboarding_done", true).apply()
startActivity(Intent(this, LoginActivity::class.java))
finish()
}
}
// Called by SecuritySetupFragment when setup is complete
override fun onSecuritySetupComplete() {
binding.viewPager.isUserInputEnabled = true
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3)
}
private fun selectLanguage(lang: String) {
getSharedPreferences("prefs", MODE_PRIVATE).edit().putString("language", lang).apply()
val locales = LocaleListCompat.forLanguageTags(lang)
AppCompatDelegate.setApplicationLocales(locales)
// Update buttons immediately in case locale didn't change (no recreation)
prefs.edit().putString("language", lang).apply()
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(lang))
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3)
}
private fun updateButtons(position: Int, count: Int) {
val langSelected = getSharedPreferences("prefs", MODE_PRIVATE).getString("language", null) != null
val langSelected = prefs.getString("language", null) != null
val securityDone = prefs.getString("security_method", null) != null
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
binding.btnNext.isEnabled = position > 0 || langSelected
// Hide Next on slide 1 until security is done (avoids a disabled-button-with-no-explanation)
binding.btnNext.visibility = when {
isLast -> View.GONE
position == 1 && !securityDone -> View.GONE
else -> View.VISIBLE
}
binding.btnNext.isEnabled = when (position) {
0 -> langSelected
1 -> securityDone
else -> true
}
}
}

View File

@@ -30,8 +30,10 @@ class OnboardingPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(
override fun getItemCount() = slides.size
override fun createFragment(position: Int): Fragment =
OnboardingFragment.newInstance(slides[position])
override fun createFragment(position: Int): Fragment = when (position) {
1 -> SecuritySetupFragment()
else -> OnboardingFragment.newInstance(slides[position])
}
}
data class OnboardingSlide(

View File

@@ -0,0 +1,120 @@
package sh.sar.basedbank.ui.onboarding
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import com.google.android.material.color.MaterialColors
import kotlin.math.min
import kotlin.math.sqrt
class PatternView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
private data class Cell(val index: Int, var cx: Float = 0f, var cy: Float = 0f)
private val cells = List(9) { Cell(it) }
private val selected = mutableListOf<Cell>()
private var touchX = 0f
private var touchY = 0f
private var recording = false
private var errorState = false
private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL }
private val ringPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
}
var onPatternComplete: ((List<Int>) -> Unit)? = null
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
val sz = min(w, h).toFloat()
val cell = sz / 3f
cells.forEachIndexed { i, c ->
c.cx = (i % 3) * cell + cell / 2f
c.cy = (i / 3) * cell + cell / 2f
}
linePaint.strokeWidth = cell * 0.07f
ringPaint.strokeWidth = cell * 0.05f
}
override fun onMeasure(ws: Int, hs: Int) {
val s = min(MeasureSpec.getSize(ws), MeasureSpec.getSize(hs))
setMeasuredDimension(s, s)
}
override fun onDraw(canvas: Canvas) {
val sz = min(width, height).toFloat()
val cell = sz / 3f
val dotR = cell * 0.13f
val ringR = cell * 0.26f
val activeColor = if (errorState)
MaterialColors.getColor(this, com.google.android.material.R.attr.colorError, Color.RED)
else
MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, Color.BLUE)
val normalColor =
MaterialColors.getColor(this, com.google.android.material.R.attr.colorOutlineVariant, Color.GRAY)
// Lines between selected cells
linePaint.color = activeColor; linePaint.alpha = 160
for (i in 0 until selected.size - 1)
canvas.drawLine(selected[i].cx, selected[i].cy, selected[i + 1].cx, selected[i + 1].cy, linePaint)
// Trailing line to finger position
if (recording && selected.isNotEmpty()) {
linePaint.alpha = 80
canvas.drawLine(selected.last().cx, selected.last().cy, touchX, touchY, linePaint)
}
// Dots
cells.forEach { c ->
val isSelected = selected.contains(c)
dotPaint.color = if (isSelected) activeColor else normalColor
canvas.drawCircle(c.cx, c.cy, dotR, dotPaint)
if (isSelected) {
ringPaint.color = activeColor; ringPaint.alpha = 60
canvas.drawCircle(c.cx, c.cy, ringR, ringPaint)
}
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
if (errorState) return false
when (event.action) {
MotionEvent.ACTION_DOWN -> {
recording = true; selected.clear()
hit(event.x, event.y)
}
MotionEvent.ACTION_MOVE -> {
touchX = event.x; touchY = event.y
hit(event.x, event.y)
}
MotionEvent.ACTION_UP -> {
recording = false
invalidate()
onPatternComplete?.invoke(selected.map { it.index })
}
}
invalidate()
return true
}
private fun hit(x: Float, y: Float) {
val hitR = min(width, height) / 3f * 0.40f
cells.forEach { c ->
val dx = x - c.cx; val dy = y - c.cy
if (sqrt(dx * dx + dy * dy) <= hitR && !selected.contains(c)) selected.add(c)
}
}
fun reset() { selected.clear(); recording = false; errorState = false; invalidate() }
fun showError() {
errorState = true; invalidate()
postDelayed({ reset() }, 700)
}
}

View File

@@ -0,0 +1,233 @@
package sh.sar.basedbank.ui.onboarding
import android.content.Context
import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.biometric.BiometricManager
import androidx.fragment.app.Fragment
import com.google.android.material.button.MaterialButton
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
import java.security.MessageDigest
import java.security.SecureRandom
class SecuritySetupFragment : Fragment() {
interface Callback {
fun onSecuritySetupComplete()
}
private var _b: FragmentSecuritySetupBinding? = null
private val b get() = _b!!
private enum class Step { CHOOSE, PIN_ENTER, PIN_CONFIRM, PATTERN_ENTER, PATTERN_CONFIRM, BIOMETRIC }
private var step = Step.CHOOSE
private val pinDigits = mutableListOf<Int>()
private var firstPin = ""
private var firstPattern: List<Int> = emptyList()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_b = FragmentSecuritySetupBinding.inflate(inflater, container, false)
return b.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
if (prefs.getString("security_method", null) != null) {
(activity as? Callback)?.onSecuritySetupComplete()
}
b.cardPin.setOnClickListener { goTo(Step.PIN_ENTER) }
b.cardPattern.setOnClickListener { goTo(Step.PATTERN_ENTER) }
b.btnPinBack.setOnClickListener {
when (step) {
Step.PIN_CONFIRM -> goTo(Step.PIN_ENTER)
else -> goTo(Step.CHOOSE)
}
}
b.btnPatternBack.setOnClickListener { goTo(Step.CHOOSE) }
b.patternView.onPatternComplete = { pattern -> handlePattern(pattern) }
b.btnEnableBiometrics.setOnClickListener {
prefs.edit().putBoolean("biometrics_enabled", true).apply()
finishSetup()
}
b.btnSkipBiometrics.setOnClickListener {
prefs.edit().putBoolean("biometrics_enabled", false).apply()
finishSetup()
}
buildNumpad()
goTo(Step.CHOOSE)
}
private fun buildNumpad() {
val rows = listOf(
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
listOf("", "0", "")
)
rows.forEach { keys ->
val row = LinearLayout(requireContext()).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f
)
}
keys.forEach { key ->
val style = if (key == "")
com.google.android.material.R.attr.materialButtonStyle
else
com.google.android.material.R.attr.materialButtonOutlinedStyle
val btn = MaterialButton(requireContext(), null, style).apply {
text = key
textSize = 20f
insetTop = 0
insetBottom = 0
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)
.also { it.setMargins(4, 4, 4, 4) }
}
btn.setOnClickListener { handleKey(key) }
row.addView(btn)
}
b.numpadContainer.addView(row)
}
}
private fun handleKey(key: String) {
when (key) {
"" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
"" -> if (pinDigits.size >= 4) submitPin()
else -> if (pinDigits.size < 8) { pinDigits.add(key.toInt()); updateDots() }
}
}
private fun updateDots() {
val n = pinDigits.size
val total = maxOf(n, 4)
b.tvPinDots.text = "".repeat(n) + "".repeat(total - n)
}
private fun submitPin() {
val entered = pinDigits.joinToString("")
when (step) {
Step.PIN_ENTER -> {
firstPin = entered
pinDigits.clear()
goTo(Step.PIN_CONFIRM)
}
Step.PIN_CONFIRM -> {
if (entered == firstPin) {
saveCredential("pin", entered)
goToBiometricOrFinish()
} else {
b.tvPinDots.text = getString(R.string.pin_no_match)
pinDigits.clear()
b.root.postDelayed({ updateDots() }, 1200)
}
}
else -> {}
}
}
private fun handlePattern(pattern: List<Int>) {
if (pattern.size < 4) {
b.patternView.showError()
b.tvPatternStatus.text = getString(R.string.pattern_min_dots)
return
}
when (step) {
Step.PATTERN_ENTER -> {
firstPattern = pattern
step = Step.PATTERN_CONFIRM
b.tvPatternTitle.text = getString(R.string.confirm_pattern)
b.tvPatternStatus.text = getString(R.string.pattern_draw_again)
b.patternView.reset()
}
Step.PATTERN_CONFIRM -> {
if (pattern == firstPattern) {
saveCredential("pattern", pattern.joinToString(""))
goToBiometricOrFinish()
} else {
b.patternView.showError()
b.tvPatternStatus.text = getString(R.string.pattern_no_match)
step = Step.PATTERN_ENTER
b.tvPatternTitle.text = getString(R.string.draw_pattern)
b.root.postDelayed({
b.tvPatternStatus.text = getString(R.string.pattern_min_dots)
}, 1200)
}
}
else -> {}
}
}
private fun goToBiometricOrFinish() {
val canAuth = BiometricManager.from(requireContext())
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)
if (canAuth == BiometricManager.BIOMETRIC_SUCCESS) {
goTo(Step.BIOMETRIC)
} else {
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
.edit().putBoolean("biometrics_enabled", false).apply()
finishSetup()
}
}
private fun goTo(s: Step) {
step = s
b.viewChooseMethod.visibility = if (s == Step.CHOOSE) View.VISIBLE else View.GONE
b.viewPinSetup.visibility = if (s == Step.PIN_ENTER || s == Step.PIN_CONFIRM) View.VISIBLE else View.GONE
b.viewPatternSetup.visibility = if (s == Step.PATTERN_ENTER || s == Step.PATTERN_CONFIRM) View.VISIBLE else View.GONE
b.viewBiometric.visibility = if (s == Step.BIOMETRIC) View.VISIBLE else View.GONE
when (s) {
Step.PIN_ENTER -> {
pinDigits.clear()
b.tvPinTitle.text = getString(R.string.enter_pin)
updateDots()
}
Step.PIN_CONFIRM -> {
b.tvPinTitle.text = getString(R.string.confirm_pin)
updateDots()
}
Step.PATTERN_ENTER -> {
b.tvPatternTitle.text = getString(R.string.draw_pattern)
b.tvPatternStatus.text = getString(R.string.pattern_min_dots)
b.patternView.reset()
}
else -> {}
}
}
private fun saveCredential(method: String, input: String) {
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
val saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP)
val hash = sha256(saltB64 + input)
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit()
.putString("security_method", method)
.putString("security_salt", saltB64)
.putString("security_hash", hash)
.apply()
}
private fun sha256(input: String) = MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
private fun finishSetup() {
(activity as? Callback)?.onSecuritySetupComplete()
}
override fun onDestroyView() {
super.onDestroyView()
_b = null
}
}

View File

@@ -0,0 +1,90 @@
package sh.sar.basedbank.util
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class CredentialStore(context: Context) {
private val prefs = context.getSharedPreferences("credential_store", Context.MODE_PRIVATE)
private val keyAlias = "basedbank_credential_key"
private val transformation = "AES/GCM/NoPadding"
data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String)
fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username")
fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) {
val key = getOrCreateKey()
prefs.edit()
.putString("mib_enc_username", encrypt(username, key))
.putString("mib_enc_password_hash", encrypt(passwordHash, key))
.putString("mib_enc_otp_seed", encrypt(otpSeed, key))
.apply()
}
fun loadMibCredentials(): MibCredentials? {
val key = getOrCreateKey()
val encUsername = prefs.getString("mib_enc_username", null) ?: return null
val encHash = prefs.getString("mib_enc_password_hash", null) ?: return null
val encSeed = prefs.getString("mib_enc_otp_seed", null) ?: return null
return try {
MibCredentials(
decrypt(encUsername, key),
decrypt(encHash, key),
decrypt(encSeed, key)
)
} catch (e: Exception) {
null
}
}
fun clearMibCredentials() {
prefs.edit()
.remove("mib_enc_username")
.remove("mib_enc_password_hash")
.remove("mib_enc_otp_seed")
.apply()
}
private fun getOrCreateKey(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
ks.getKey(keyAlias, null)?.let { return it as SecretKey }
val spec = KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
.also { it.init(spec) }
.generateKey()
}
private fun encrypt(plaintext: String, key: SecretKey): String {
val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.ENCRYPT_MODE, key)
val iv = cipher.iv
val ct = cipher.doFinal(plaintext.toByteArray())
return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" + Base64.encodeToString(ct, Base64.NO_WRAP)
}
private fun decrypt(encoded: String, key: SecretKey): String {
val parts = encoded.split(":")
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ct = Base64.decode(parts[1], Base64.NO_WRAP)
val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
return String(cipher.doFinal(ct))
}
}

View File

@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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">
<!-- PIN unlock -->
<LinearLayout
android:id="@+id/viewPin"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingTop="48dp"
android:paddingHorizontal="16dp"
android:paddingBottom="16dp"
android:visibility="gone">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/unlock_app"
android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:textColor="?attr/colorOnSurface"
android:gravity="center"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/unlock_pin_subtitle"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center"
android:layout_marginBottom="24dp" />
<TextView
android:id="@+id/tvLockPinDots"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textSize="26sp"
android:letterSpacing="0.3"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="20dp" />
<LinearLayout
android:id="@+id/lockNumpadContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLockBiometricFromPin"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="4dp"
android:text="@string/use_biometrics"
android:visibility="gone" />
</LinearLayout>
<!-- Pattern unlock -->
<LinearLayout
android:id="@+id/viewPattern"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingTop="48dp"
android:paddingHorizontal="32dp"
android:paddingBottom="16dp"
android:gravity="center_horizontal"
android:visibility="gone">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/unlock_app"
android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:textColor="?attr/colorOnSurface"
android:gravity="center"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/unlock_pattern_subtitle"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center"
android:layout_marginBottom="24dp" />
<TextView
android:id="@+id/tvPatternHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center"
android:layout_marginBottom="12dp" />
<sh.sar.basedbank.ui.onboarding.PatternView
android:id="@+id/lockPatternView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLockBiometricFromPattern"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/use_biometrics"
android:visibility="gone" />
</LinearLayout>
</FrameLayout>

View File

@@ -18,4 +18,29 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<LinearLayout
android:id="@+id/autoLoginGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<ProgressBar
android:layout_width="48dp"
android:layout_height="48dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/signing_in"
android:textAppearance="?attr/textAppearanceBodyMedium" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,311 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout 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">
<!-- Step: Choose method -->
<ScrollView
android:id="@+id/viewChooseMethod"
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="40dp"
android:paddingBottom="24dp"
android:gravity="center_horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🔒"
android:textSize="56sp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/security_setup"
android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:textColor="?attr/colorOnSurface"
android:gravity="center"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/security_setup_desc"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center"
android:layout_marginBottom="40dp" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardPin"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
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="horizontal"
android:padding="20dp"
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/method_pin"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/method_pin_desc"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textSize="24sp"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardPattern"
android:layout_width="match_parent"
android:layout_height="wrap_content"
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="horizontal"
android:padding="20dp"
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/method_pattern"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/method_pattern_desc"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:textSize="24sp"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>
<!-- Step: PIN entry / confirmation -->
<LinearLayout
android:id="@+id/viewPinSetup"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingTop="32dp"
android:paddingHorizontal="16dp"
android:paddingBottom="8dp"
android:visibility="gone">
<TextView
android:id="@+id/tvPinTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/enter_pin"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorOnSurface"
android:gravity="center"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pin_min_digits"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center"
android:layout_marginBottom="20dp" />
<TextView
android:id="@+id/tvPinDots"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textSize="26sp"
android:letterSpacing="0.3"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="20dp" />
<LinearLayout
android:id="@+id/numpadContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPinBack"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="4dp"
android:text="@string/back" />
</LinearLayout>
<!-- Step: Pattern entry / confirmation -->
<LinearLayout
android:id="@+id/viewPatternSetup"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingTop="32dp"
android:paddingHorizontal="32dp"
android:paddingBottom="8dp"
android:gravity="center_horizontal"
android:visibility="gone">
<TextView
android:id="@+id/tvPatternTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/draw_pattern"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorOnSurface"
android:gravity="center"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/tvPatternStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pattern_min_dots"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center"
android:layout_marginBottom="16dp" />
<sh.sar.basedbank.ui.onboarding.PatternView
android:id="@+id/patternView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPatternBack"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/back" />
</LinearLayout>
<!-- Step: Biometric prompt -->
<LinearLayout
android:id="@+id/viewBiometric"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingHorizontal="24dp"
android:paddingTop="40dp"
android:paddingBottom="24dp"
android:gravity="center_horizontal"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🔐"
android:textSize="72sp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/biometric_title"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorOnSurface"
android:gravity="center"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/biometric_desc"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center"
android:layout_marginBottom="16dp" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
app:cardBackgroundColor="?attr/colorSecondaryContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="14dp"
android:text="@string/biometric_security_note"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSecondaryContainer"
android:gravity="center" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnEnableBiometrics"
android:layout_width="match_parent"
android:layout_height="56dp"
android:text="@string/enable_biometrics"
android:layout_marginBottom="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSkipBiometrics"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/skip_biometrics" />
</LinearLayout>
</FrameLayout>

View File

@@ -28,6 +28,40 @@
<string name="otp_seed_hint">ތިޔަ އޮތެންޓިކޭޓަ ދިން Base32 ސިއްރު</string>
<string name="login">ލޮގިން</string>
<!-- Lock screen -->
<string name="unlock_app">BasedBank ހުޅުވާ</string>
<string name="unlock_pin_subtitle">PIN ޖަހާ</string>
<string name="unlock_pattern_subtitle">ހުޅުވާ ޕެޓަން ކަހާ</string>
<string name="use_biometrics">ބަޔޮމެޓްރިކް ބޭނުން ކުރޭ</string>
<string name="biometric_prompt_subtitle">ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވާ</string>
<string name="biometric_negative_btn">PIN / ޕެޓަން ބޭނުން ކުރޭ</string>
<string name="unlock_failed">ދިމައެއް ނުވި — އަލުން ކަނޑޭ</string>
<!-- Security setup -->
<string name="security_setup">އެޕް ރައްކާތެރި ކުރޭ</string>
<string name="security_setup_desc">BasedBank ހުޅުވަން ބޭނުންވާ ގޮތެއް ހިޔާރު ކުރޭ.</string>
<string name="method_pin">PIN ކޯޑް</string>
<string name="method_pin_desc">48 ރިޔަލެއްގެ ނަންބަރު PIN</string>
<string name="method_pattern">ޕެޓަން ކަހާ</string>
<string name="method_pattern_desc">4 ނުވަތަ އެއަށްވުރެ ގިނަ ތިކި ގުޅުވާ</string>
<string name="enter_pin">PIN ޖަހާ</string>
<string name="confirm_pin">PIN ކަށަވަރު ކުރޭ</string>
<string name="pin_min_digits">މަދުވެގެން 4 ރިޔަލ، ގިނަވެގެން 8</string>
<string name="pin_no_match">PIN ދިމައެއް ނުވި — އަލުން ކަނޑޭ</string>
<string name="draw_pattern">ޕެޓަން ކަހާ</string>
<string name="confirm_pattern">ޕެޓަން ކަށަވަރު ކުރޭ</string>
<string name="pattern_min_dots">މަދުވެގެން 4 ތިކި ގުޅުވާ</string>
<string name="pattern_draw_again">އެ ޕެޓަން އަލުން ކަހާ</string>
<string name="pattern_no_match">ޕެޓަން ދިމައެއް ނުވި — އަލުން ކަހާ</string>
<string name="biometric_title">ބަޔޮމެޓްރިކް ބޭނުން ކުރަންތަ؟</string>
<string name="biometric_desc">ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވޭ.</string>
<string name="biometric_security_note">ބަޔޮމެޓްރިކް ފަސޭހަ ނަމަވެސް PIN ނުވަތަ ޕެޓަނަށްވުރެ ތަންކޮޅެއް ކަށަވަރެއް ނޫން. ރައްކާތެރިކަން ބޭނުން ނަމަ PIN ނުވަތަ ޕެޓަން ހިޔާރު ކުރޭ.</string>
<string name="enable_biometrics">ބަޔޮމެޓްރިކް ހިންގާ</string>
<string name="skip_biometrics">ސްކިޕް — PIN/ޕެޓަން ބޭނުން ކުރޭ</string>
<string name="back">ފަހަތަށް</string>
<string name="signing_in">ލޮގިން ވަނީ…</string>
<!-- Home -->
<string name="accounts">އެކައުންޓްތައް</string>
<string name="available_balance">ލިބެން ހުރި ބެލެންސް</string>

View File

@@ -27,6 +27,40 @@
<string name="otp_seed_hint">The Base32 secret from your authenticator setup</string>
<string name="login">Login</string>
<!-- Lock screen -->
<string name="unlock_app">Unlock BasedBank</string>
<string name="unlock_pin_subtitle">Enter your PIN</string>
<string name="unlock_pattern_subtitle">Draw your unlock pattern</string>
<string name="use_biometrics">Use Biometrics</string>
<string name="biometric_prompt_subtitle">Use your fingerprint or face to unlock</string>
<string name="biometric_negative_btn">Use PIN / Pattern</string>
<string name="unlock_failed">Incorrect — try again</string>
<!-- Security setup -->
<string name="security_setup">Secure Your App</string>
<string name="security_setup_desc">Choose how you want to lock BasedBank when you\'re away.</string>
<string name="method_pin">PIN Code</string>
<string name="method_pin_desc">48 digit numeric PIN</string>
<string name="method_pattern">Draw Pattern</string>
<string name="method_pattern_desc">Connect 4 or more dots in a pattern</string>
<string name="enter_pin">Set Your PIN</string>
<string name="confirm_pin">Confirm Your PIN</string>
<string name="pin_min_digits">Minimum 4 digits, maximum 8</string>
<string name="pin_no_match">PINs don\'t match — try again</string>
<string name="draw_pattern">Draw Your Pattern</string>
<string name="confirm_pattern">Confirm Your Pattern</string>
<string name="pattern_min_dots">Connect at least 4 dots</string>
<string name="pattern_draw_again">Draw the same pattern again</string>
<string name="pattern_no_match">Patterns don\'t match — try again</string>
<string name="biometric_title">Use Biometrics?</string>
<string name="biometric_desc">Unlock with your fingerprint or face scan instead of your PIN or pattern.</string>
<string name="biometric_security_note">Biometrics is convenient but slightly less secure than a PIN or pattern. For maximum security, use PIN or pattern only.</string>
<string name="enable_biometrics">Enable Biometrics</string>
<string name="skip_biometrics">Skip — use PIN/Pattern only</string>
<string name="back">Back</string>
<string name="signing_in">Signing in…</string>
<!-- Home -->
<string name="accounts">Accounts</string>
<string name="available_balance">Available Balance</string>