forked from shihaam/thijooree
322 lines
12 KiB
Kotlin
322 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.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<Int>()
|
|
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<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) {
|
|
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) }
|
|
|
|
|
|
}
|