Files
thijooree/app/src/main/java/sh/sar/basedbank/LockActivity.kt
T
shihaam dd620763ec
Auto Tag on Version Change / check-version (push) Successful in 5s
new feature: add launcher shortcuts
2026-05-28 14:06:49 +05:00

312 lines
12 KiB
Kotlin

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.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
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 sh.sar.basedbank.BasedBankApp
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
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
private var autoUnlockPin = false
private var pinLength = 4
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)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLockBinding.inflate(layoutInflater)
setContentView(binding.root)
val isLight = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_NO
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
insets
}
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
method = prefs.getString("security_method", "pin") ?: "pin"
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
autoUnlockPin = prefs.getBoolean("auto_unlock_pin", false)
pinLength = prefs.getInt("pin_length", 4)
val stored = CredentialStore(this).loadSecurityHash() ?: run { finish(); return }
salt = stored.first
storedHash = stored.second
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()
if (autoUnlockPin && pinDigits.size == pinLength) verifyPin()
}
}
}
private fun updateDots() {
val n = pinDigits.size
val total = if (autoUnlockPin) pinLength else maxOf(n, 4)
binding.tvLockPinDots.text = "".repeat(n) + "".repeat(maxOf(total - 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) {
resetFailures()
proceed()
} else {
showFailure()
}
}
}
private fun verifyPattern(pattern: List<Int>) {
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) {
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.tvPinHint.text = msg
binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, remaining)
return true
}
private fun showFailure() {
val msg = failureMessage()
binding.tvPinHint.text = msg
binding.root.postDelayed({ binding.tvPinHint.text = ""; 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
val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
return pbkdf2(input, saltBytes) == 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) {
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() {
(application as BasedBankApp).isUnlocked = true
if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
finish()
} else {
val navDest = intent.getIntExtra("nav_destination", -1)
val autoScan = intent.getBooleanExtra("auto_scan", false)
startActivity(Intent(this, HomeActivity::class.java).apply {
if (navDest != -1) putExtra("nav_destination", navDest)
if (autoScan) putExtra("auto_scan", true)
})
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()
}
}
}