add app lock secruity and support for saving mib credentials

This commit is contained in:
2026-05-14 05:24:13 +05:00
parent 7209e9dca0
commit 81a2be150f
17 changed files with 1259 additions and 35 deletions
@@ -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
}
}
}
@@ -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(
@@ -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<Cell>()
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<Int>) -> 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)
}
}
@@ -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<Int>()
private var firstPin = ""
private var firstPattern: List<Int> = 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<Int>) {
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
}
}