diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 24ce419..a9f712a 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8235bda..822d33e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
+
+
+
diff --git a/app/src/main/java/sh/sar/basedbank/LockActivity.kt b/app/src/main/java/sh/sar/basedbank/LockActivity.kt
new file mode 100644
index 0000000..ecd0151
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/LockActivity.kt
@@ -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()
+ 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) {
+ 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)
+ }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/MainActivity.kt b/app/src/main/java/sh/sar/basedbank/MainActivity.kt
index 18ced06..37b17e8 100644
--- a/app/src/main/java/sh/sar/basedbank/MainActivity.kt
+++ b/app/src/main/java/sh/sar/basedbank/MainActivity.kt
@@ -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()
}
diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt
index d1b29a5..19b0625 100644
--- a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt
+++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt
@@ -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 {
+ fun login(username: String, passwordHash: String, otpSeed: String): List {
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 {
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 {
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 {
diff --git a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt
index 3ac24c8..0528a8b 100644
--- a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt
+++ b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt
@@ -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()
diff --git a/app/src/main/java/sh/sar/basedbank/ui/login/LoginActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/login/LoginActivity.kt
index eda7912..0bbffcb 100644
--- a/app/src/main/java/sh/sar/basedbank/ui/login/LoginActivity.kt
+++ b/app/src/main/java/sh/sar/basedbank/ui/login/LoginActivity.kt
@@ -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
+ }
+ }
}
}
diff --git a/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingActivity.kt
index 9d8d20a..440992f 100644
--- a/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingActivity.kt
+++ b/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingActivity.kt
@@ -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
+ }
}
}
diff --git a/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingPagerAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingPagerAdapter.kt
index e6c5170..1a81217 100644
--- a/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingPagerAdapter.kt
+++ b/app/src/main/java/sh/sar/basedbank/ui/onboarding/OnboardingPagerAdapter.kt
@@ -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(
diff --git a/app/src/main/java/sh/sar/basedbank/ui/onboarding/PatternView.kt b/app/src/main/java/sh/sar/basedbank/ui/onboarding/PatternView.kt
new file mode 100644
index 0000000..dd887f1
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/ui/onboarding/PatternView.kt
@@ -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| ()
+ 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) -> 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)
+ }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/ui/onboarding/SecuritySetupFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/onboarding/SecuritySetupFragment.kt
new file mode 100644
index 0000000..1b76154
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/ui/onboarding/SecuritySetupFragment.kt
@@ -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()
+ private var firstPin = ""
+ private var firstPattern: List = 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) {
+ 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
+ }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt
new file mode 100644
index 0000000..d6db1fb
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt
@@ -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))
+ }
+}
diff --git a/app/src/main/res/layout/activity_lock.xml b/app/src/main/res/layout/activity_lock.xml
new file mode 100644
index 0000000..1360160
--- /dev/null
+++ b/app/src/main/res/layout/activity_lock.xml
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml
index 6551d2d..08d3039 100644
--- a/app/src/main/res/layout/activity_login.xml
+++ b/app/src/main/res/layout/activity_login.xml
@@ -18,4 +18,29 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_security_setup.xml b/app/src/main/res/layout/fragment_security_setup.xml
new file mode 100644
index 0000000..15802ad
--- /dev/null
+++ b/app/src/main/res/layout/fragment_security_setup.xml
@@ -0,0 +1,311 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-b+dv/strings.xml b/app/src/main/res/values-b+dv/strings.xml
index ffa23d7..f7b0b38 100644
--- a/app/src/main/res/values-b+dv/strings.xml
+++ b/app/src/main/res/values-b+dv/strings.xml
@@ -28,6 +28,40 @@
ތިޔަ އޮތެންޓިކޭޓަ ދިން Base32 ސިއްރު
ލޮގިން
+
+ BasedBank ހުޅުވާ
+ PIN ޖަހާ
+ ހުޅުވާ ޕެޓަން ކަހާ
+ ބަޔޮމެޓްރިކް ބޭނުން ކުރޭ
+ ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވާ
+ PIN / ޕެޓަން ބޭނުން ކުރޭ
+ ދިމައެއް ނުވި — އަލުން ކަނޑޭ
+
+
+ އެޕް ރައްކާތެރި ކުރޭ
+ BasedBank ހުޅުވަން ބޭނުންވާ ގޮތެއް ހިޔާރު ކުރޭ.
+ PIN ކޯޑް
+ 4–8 ރިޔަލެއްގެ ނަންބަރު PIN
+ ޕެޓަން ކަހާ
+ 4 ނުވަތަ އެއަށްވުރެ ގިނަ ތިކި ގުޅުވާ
+ PIN ޖަހާ
+ PIN ކަށަވަރު ކުރޭ
+ މަދުވެގެން 4 ރިޔަލ، ގިނަވެގެން 8
+ PIN ދިމައެއް ނުވި — އަލުން ކަނޑޭ
+ ޕެޓަން ކަހާ
+ ޕެޓަން ކަށަވަރު ކުރޭ
+ މަދުވެގެން 4 ތިކި ގުޅުވާ
+ އެ ޕެޓަން އަލުން ކަހާ
+ ޕެޓަން ދިމައެއް ނުވި — އަލުން ކަހާ
+ ބަޔޮމެޓްރިކް ބޭނުން ކުރަންތަ؟
+ ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވޭ.
+ ބަޔޮމެޓްރިކް ފަސޭހަ ނަމަވެސް PIN ނުވަތަ ޕެޓަނަށްވުރެ ތަންކޮޅެއް ކަށަވަރެއް ނޫން. ރައްކާތެރިކަން ބޭނުން ނަމަ PIN ނުވަތަ ޕެޓަން ހިޔާރު ކުރޭ.
+ ބަޔޮމެޓްރިކް ހިންގާ
+ ސްކިޕް — PIN/ޕެޓަން ބޭނުން ކުރޭ
+ ފަހަތަށް
+
+ ލޮގިން ވަނީ…
+
އެކައުންޓްތައް
ލިބެން ހުރި ބެލެންސް
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 1568e4b..4cc0f66 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -27,6 +27,40 @@
The Base32 secret from your authenticator setup
Login
+
+ Unlock BasedBank
+ Enter your PIN
+ Draw your unlock pattern
+ Use Biometrics
+ Use your fingerprint or face to unlock
+ Use PIN / Pattern
+ Incorrect — try again
+
+
+ Secure Your App
+ Choose how you want to lock BasedBank when you\'re away.
+ PIN Code
+ 4–8 digit numeric PIN
+ Draw Pattern
+ Connect 4 or more dots in a pattern
+ Set Your PIN
+ Confirm Your PIN
+ Minimum 4 digits, maximum 8
+ PINs don\'t match — try again
+ Draw Your Pattern
+ Confirm Your Pattern
+ Connect at least 4 dots
+ Draw the same pattern again
+ Patterns don\'t match — try again
+ Use Biometrics?
+ Unlock with your fingerprint or face scan instead of your PIN or pattern.
+ Biometrics is convenient but slightly less secure than a PIN or pattern. For maximum security, use PIN or pattern only.
+ Enable Biometrics
+ Skip — use PIN/Pattern only
+ Back
+
+ Signing in…
+
Accounts
Available Balance
|