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.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import com.google.android.material.button.MaterialButton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import sh.sar.basedbank.databinding.ActivityLockBinding import sh.sar.basedbank.ui.home.HomeActivity import sh.sar.basedbank.util.CredentialStore import java.security.MessageDigest import java.security.SecureRandom import javax.crypto.SecretKeyFactory import javax.crypto.spec.PBEKeySpec 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 private var isLegacyFormat = false private var isVerifying = false private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE) companion object { private const val MAX_ATTEMPTS = 5 private const val LOCKOUT_MS = 30_000L const val EXTRA_RESUME = "resume" } 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" biometricsEnabled = prefs.getBoolean("biometrics_enabled", false) // Try new encrypted format first; fall back to legacy SHA-256 val stored = CredentialStore(this).loadSecurityHash() if (stored != null) { salt = stored.first storedHash = stored.second isLegacyFormat = false } else { salt = prefs.getString("security_salt", "") ?: "" storedHash = prefs.getString("security_hash", "") ?: "" isLegacyFormat = true } 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() onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { moveTaskToBack(true) } }) } private fun buildNumpad() { val dp = resources.displayMetrics.density val btnSize = (68 * dp).toInt() val btnMarginH = (10 * dp).toInt() val rowMarginV = (6 * dp).toInt() 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 gravity = android.view.Gravity.CENTER layoutParams = LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT ).also { it.setMargins(0, rowMarginV, 0, rowMarginV) } } 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 = 24f insetTop = 0; insetBottom = 0 minimumWidth = 0; minimumHeight = 0 cornerRadius = btnSize / 2 layoutParams = LinearLayout.LayoutParams(btnSize, btnSize) .also { it.setMargins(btnMarginH, 0, btnMarginH, 0) } } btn.setOnClickListener { handleKey(key) } row.addView(btn) } binding.lockNumpadContainer.addView(row) } } private fun handleKey(key: String) { if (isVerifying) return 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() { if (checkAndShowLockout()) return val entered = pinDigits.joinToString("") pinDigits.clear() updateDots() isVerifying = true lifecycleScope.launch { val ok = withContext(Dispatchers.Default) { verify(entered) } isVerifying = false if (ok) { migrateIfNeeded(entered) resetFailures() proceed() } else { showFailure() } } } private fun verifyPattern(pattern: List) { if (pattern.size < 4) { binding.lockPatternView.showError() binding.tvPatternHint.text = getString(R.string.pattern_min_dots) return } if (checkAndShowLockout()) { binding.lockPatternView.showError() return } val entered = pattern.joinToString("") isVerifying = true lifecycleScope.launch { val ok = withContext(Dispatchers.Default) { verify(entered) } isVerifying = false if (ok) { migrateIfNeeded(entered) resetFailures() proceed() } else { binding.lockPatternView.showError() val msg = failureMessage() binding.tvPatternHint.text = msg binding.root.postDelayed({ binding.tvPatternHint.text = "" }, 1500) } } } /** Returns true and shows the lockout message if currently locked out. */ private fun checkAndShowLockout(): Boolean { val remaining = lockoutRemainingMs() if (remaining <= 0) return false val secs = ((remaining + 999L) / 1000L).toInt() val msg = getString(R.string.unlock_locked_out, secs) binding.tvLockPinDots.text = msg binding.root.postDelayed({ updateDots() }, remaining) return true } private fun showFailure() { val msg = failureMessage() binding.tvLockPinDots.text = msg binding.root.postDelayed({ updateDots() }, 1200) } private fun failureMessage(): String { val fails = incrementFailures() val left = MAX_ATTEMPTS - fails return if (left <= 0) { val secs = (LOCKOUT_MS / 1000L).toInt() getString(R.string.unlock_locked_out, secs) } else { getString(R.string.unlock_attempts_remaining, left) } } private fun verify(input: String): Boolean { if (storedHash.isBlank()) return false return if (isLegacyFormat) { sha256Legacy(salt + input) == storedHash } else { val saltBytes = Base64.decode(salt, Base64.NO_WRAP) pbkdf2(input, saltBytes) == storedHash } } /** * On the first successful unlock after legacy SHA-256 format is detected, * transparently migrate to PBKDF2 + CredentialStore. */ private fun migrateIfNeeded(input: String) { if (!isLegacyFormat) return try { val newSalt = ByteArray(16).also { SecureRandom().nextBytes(it) } val newHash = pbkdf2(input, newSalt) val saltB64 = Base64.encodeToString(newSalt, Base64.NO_WRAP) CredentialStore(this).saveSecurityHash(saltB64, newHash) // Remove legacy plaintext fields getSharedPreferences("prefs", MODE_PRIVATE).edit() .remove("security_salt").remove("security_hash").apply() isLegacyFormat = false } catch (_: Exception) { /* migration will retry next unlock */ } } 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) { resetFailures() 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() { if (intent.getBooleanExtra(EXTRA_RESUME, false)) { finish() } else { startActivity(Intent(this, HomeActivity::class.java)) finish() } } // ── Brute-force tracking ────────────────────────────────────────────────── private fun incrementFailures(): Int { val fails = lockPrefs.getInt("fail_count", 0) + 1 lockPrefs.edit() .putInt("fail_count", fails) .putLong("last_fail_time", System.currentTimeMillis()) .apply() return fails } private fun resetFailures() { lockPrefs.edit().remove("fail_count").remove("last_fail_time").apply() } private fun lockoutRemainingMs(): Long { val fails = lockPrefs.getInt("fail_count", 0) if (fails < MAX_ATTEMPTS) return 0L val lastFail = lockPrefs.getLong("last_fail_time", 0L) return maxOf(0L, LOCKOUT_MS - (System.currentTimeMillis() - lastFail)) } // ── Crypto ──────────────────────────────────────────────────────────────── private fun pbkdf2(input: String, salt: ByteArray): String { val spec = PBEKeySpec(input.toCharArray(), salt, 100_000, 256) return try { val hash = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded Base64.encodeToString(hash, Base64.NO_WRAP) } finally { spec.clearPassword() } } /** Legacy: raw SHA-256(salt + input) — only used for migration path. */ private fun sha256Legacy(input: String) = MessageDigest.getInstance("SHA-256") .digest(input.toByteArray()).joinToString("") { "%02x".format(it) } }