312 lines
12 KiB
Kotlin
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()
|
|
}
|
|
}
|
|
|
|
}
|