add app lock secruity and support for saving mib credentials
This commit is contained in:
@@ -61,6 +61,9 @@ dependencies {
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
|
||||
// Biometric authentication
|
||||
implementation("androidx.biometric:biometric:1.1.0")
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
@@ -29,6 +30,11 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".LockActivity"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.onboarding.OnboardingActivity"
|
||||
android:exported="false" />
|
||||
|
||||
168
app/src/main/java/sh/sar/basedbank/LockActivity.kt
Normal file
168
app/src/main/java/sh/sar/basedbank/LockActivity.kt
Normal file
@@ -0,0 +1,168 @@
|
||||
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.appcompat.app.AppCompatActivity
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import sh.sar.basedbank.databinding.ActivityLockBinding
|
||||
import sh.sar.basedbank.ui.login.LoginActivity
|
||||
import java.security.MessageDigest
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
salt = prefs.getString("security_salt", "") ?: ""
|
||||
storedHash = prefs.getString("security_hash", "") ?: ""
|
||||
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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(this).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(this, 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)
|
||||
}
|
||||
binding.lockNumpadContainer.addView(row)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleKey(key: String) {
|
||||
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() {
|
||||
val entered = pinDigits.joinToString("")
|
||||
if (verify(entered)) {
|
||||
proceed()
|
||||
} else {
|
||||
binding.tvLockPinDots.text = getString(R.string.unlock_failed)
|
||||
pinDigits.clear()
|
||||
binding.root.postDelayed({ updateDots() }, 1200)
|
||||
}
|
||||
}
|
||||
|
||||
private fun verifyPattern(pattern: List<Int>) {
|
||||
if (pattern.size < 4) {
|
||||
binding.lockPatternView.showError()
|
||||
binding.tvPatternHint.text = getString(R.string.pattern_min_dots)
|
||||
return
|
||||
}
|
||||
if (verify(pattern.joinToString(""))) {
|
||||
proceed()
|
||||
} else {
|
||||
binding.lockPatternView.showError()
|
||||
binding.tvPatternHint.text = getString(R.string.unlock_failed)
|
||||
binding.root.postDelayed({ binding.tvPatternHint.text = "" }, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
private fun verify(input: String): Boolean {
|
||||
val hash = sha256(salt + input)
|
||||
return hash == 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) {
|
||||
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() {
|
||||
startActivity(Intent(this, LoginActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun sha256(input: String) = MessageDigest.getInstance("SHA-256")
|
||||
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
|
||||
override fun onBackPressed() {
|
||||
// Lock screen cannot be dismissed
|
||||
moveTaskToBack(true)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,12 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
||||
val target = if (onboardingDone) LoginActivity::class.java else OnboardingActivity::class.java
|
||||
val securitySet = prefs.getString("security_method", null) != null
|
||||
val target = when {
|
||||
!onboardingDone -> OnboardingActivity::class.java
|
||||
securitySet -> LockActivity::class.java
|
||||
else -> LoginActivity::class.java
|
||||
}
|
||||
startActivity(Intent(this, target))
|
||||
finish()
|
||||
}
|
||||
|
||||
@@ -35,9 +35,10 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
* Full login flow. Automatically handles first-time device registration
|
||||
* vs. subsequent logins using stored key1/key2.
|
||||
*
|
||||
* @param passwordHash SHA-256(password) uppercase hex — use [hashPassword] to compute.
|
||||
* Returns list of accounts from all profiles on success.
|
||||
*/
|
||||
fun login(username: String, password: String, otpSeed: String): List<MibAccount> {
|
||||
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
|
||||
val appId = getOrCreateAppId()
|
||||
Log.d(TAG, "login: appId=$appId")
|
||||
val key1 = prefs.getString("mib_key1_$username", null)
|
||||
@@ -46,17 +47,17 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
|
||||
return if (key1 != null && key2 != null) {
|
||||
Log.d(TAG, "login: taking regular login path")
|
||||
regularLogin(username, password, appId, key1, key2)
|
||||
regularLogin(username, passwordHash, appId, key1, key2)
|
||||
} else {
|
||||
Log.d(TAG, "login: taking first-time registration path")
|
||||
firstTimeRegistration(username, password, otpSeed, appId)
|
||||
firstTimeRegistration(username, passwordHash, otpSeed, appId)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── First-time registration ──────────────────────────────────────────────
|
||||
|
||||
private fun firstTimeRegistration(
|
||||
username: String, password: String, otpSeed: String, appId: String
|
||||
username: String, passwordHash: String, otpSeed: String, appId: String
|
||||
): List<MibAccount> {
|
||||
Log.d(TAG, "[reg] step 0: key exchange (sfunc=r)")
|
||||
val (session1, _) = initialKeyExchange(appId, MibCrypto.DEFAULT_KEY, "r")
|
||||
@@ -67,9 +68,9 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
Log.d(TAG, "[reg] step 1 done: userSalt length=${userSalt.length}")
|
||||
|
||||
Log.d(TAG, "[reg] step 2: registration init (C41)")
|
||||
Log.d(TAG, "[reg] username='$username' password='$password' userSalt='$userSalt'")
|
||||
Log.d(TAG, "[reg] username='$username' userSalt='$userSalt'")
|
||||
val clientSalt = randomAlpha(32)
|
||||
val pgf03 = computePgf03(password, userSalt, clientSalt)
|
||||
val pgf03 = computePgf03(passwordHash, userSalt, clientSalt)
|
||||
Log.d(TAG, "[reg] pgf03=$pgf03")
|
||||
val regInitPayload = baseData(session1, "C41").apply {
|
||||
put("uname", username)
|
||||
@@ -102,13 +103,13 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
Log.d(TAG, "[reg] stored key1/key2 for user=$username")
|
||||
prefs.edit().putString("mib_key1_$username", key1).putString("mib_key2_$username", key2).apply()
|
||||
|
||||
return regularLogin(username, password, appId, key1, key2)
|
||||
return regularLogin(username, passwordHash, appId, key1, key2)
|
||||
}
|
||||
|
||||
// ─── Regular login ────────────────────────────────────────────────────────
|
||||
|
||||
private fun regularLogin(
|
||||
username: String, password: String,
|
||||
username: String, passwordHash: String,
|
||||
appId: String, key1: String, key2: String
|
||||
): List<MibAccount> {
|
||||
Log.d(TAG, "[login] step 4: key exchange (sfunc=i)")
|
||||
@@ -121,7 +122,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
|
||||
Log.d(TAG, "[login] step 6: login init (A41)")
|
||||
val clientSalt = randomAlpha(32)
|
||||
val pgf03 = computePgf03(password, userSalt, clientSalt)
|
||||
val pgf03 = computePgf03(passwordHash, userSalt, clientSalt)
|
||||
Log.d(TAG, "[login] pgf03 length=${pgf03.length}")
|
||||
val loginPayload = baseData(session2, "A41").apply {
|
||||
put("uname", username)
|
||||
@@ -273,16 +274,24 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
||||
return response.body?.string() ?: throw IllegalStateException("Empty response body")
|
||||
}
|
||||
|
||||
private fun computePgf03(password: String, userSalt: String, clientSalt: String): String {
|
||||
fun sha256Upper(input: String) = MessageDigest.getInstance("SHA-256")
|
||||
.digest(input.toByteArray())
|
||||
.joinToString("") { "%02X".format(it) }
|
||||
|
||||
val h1 = sha256Upper(password)
|
||||
/** @param h1 SHA-256(password) uppercase hex, as returned by [hashPassword] */
|
||||
private fun computePgf03(h1: String, userSalt: String, clientSalt: String): String {
|
||||
val h2 = sha256Upper(h1 + userSalt)
|
||||
return sha256Upper(clientSalt + h2)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Returns SHA-256(password) as uppercase hex. Store this instead of the raw password. */
|
||||
fun hashPassword(password: String): String =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(password.toByteArray())
|
||||
.joinToString("") { "%02X".format(it) }
|
||||
}
|
||||
|
||||
private fun sha256Upper(input: String) = MessageDigest.getInstance("SHA-256")
|
||||
.digest(input.toByteArray())
|
||||
.joinToString("") { "%02X".format(it) }
|
||||
|
||||
private fun generateOtp(seed: String): String = Totp.generate(seed)
|
||||
|
||||
private fun getOrCreateAppId(): String {
|
||||
|
||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.util.Totp
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
|
||||
@@ -98,6 +99,7 @@ class CredentialsFragment : Fragment() {
|
||||
binding.progressBar.visibility = View.VISIBLE
|
||||
binding.btnLogin.isEnabled = false
|
||||
|
||||
val passwordHash = MibLoginFlow.hashPassword(password)
|
||||
val prefs = requireContext().getSharedPreferences("mib_prefs", android.content.Context.MODE_PRIVATE)
|
||||
val flow = MibLoginFlow(prefs)
|
||||
|
||||
@@ -105,9 +107,10 @@ class CredentialsFragment : Fragment() {
|
||||
try {
|
||||
Log.d(TAG, "Starting login flow on IO dispatcher")
|
||||
val accounts = withContext(Dispatchers.IO) {
|
||||
flow.login(username, password, otpSeed)
|
||||
flow.login(username, passwordHash, otpSeed)
|
||||
}
|
||||
Log.d(TAG, "Login succeeded, got ${accounts.size} accounts")
|
||||
CredentialStore(requireContext()).saveMibCredentials(username, passwordHash, otpSeed)
|
||||
(requireActivity().application as BasedBankApp).accounts = accounts
|
||||
startActivity(Intent(requireContext(), HomeActivity::class.java))
|
||||
requireActivity().finish()
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
package sh.sar.basedbank.ui.login
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.databinding.ActivityLoginBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
|
||||
class LoginActivity : AppCompatActivity() {
|
||||
|
||||
@@ -14,5 +23,31 @@ class LoginActivity : AppCompatActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityLoginBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
val creds = CredentialStore(this).loadMibCredentials()
|
||||
if (creds != null) {
|
||||
binding.navHostFragment.visibility = View.GONE
|
||||
binding.autoLoginGroup.visibility = View.VISIBLE
|
||||
autoLogin(creds)
|
||||
}
|
||||
}
|
||||
|
||||
private fun autoLogin(creds: CredentialStore.MibCredentials) {
|
||||
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
|
||||
val flow = MibLoginFlow(prefs)
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val accounts = withContext(Dispatchers.IO) {
|
||||
flow.login(creds.username, creds.passwordHash, creds.otpSeed)
|
||||
}
|
||||
(application as BasedBankApp).accounts = accounts
|
||||
startActivity(Intent(this@LoginActivity, HomeActivity::class.java))
|
||||
finish()
|
||||
} catch (e: Exception) {
|
||||
// Auto-login failed — fall back to manual login form
|
||||
binding.autoLoginGroup.visibility = View.GONE
|
||||
binding.navHostFragment.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
120
app/src/main/java/sh/sar/basedbank/ui/onboarding/PatternView.kt
Normal file
120
app/src/main/java/sh/sar/basedbank/ui/onboarding/PatternView.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
90
app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt
Normal file
90
app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt
Normal file
@@ -0,0 +1,90 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
class CredentialStore(context: Context) {
|
||||
|
||||
private val prefs = context.getSharedPreferences("credential_store", Context.MODE_PRIVATE)
|
||||
private val keyAlias = "basedbank_credential_key"
|
||||
private val transformation = "AES/GCM/NoPadding"
|
||||
|
||||
data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String)
|
||||
|
||||
fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username")
|
||||
|
||||
fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) {
|
||||
val key = getOrCreateKey()
|
||||
prefs.edit()
|
||||
.putString("mib_enc_username", encrypt(username, key))
|
||||
.putString("mib_enc_password_hash", encrypt(passwordHash, key))
|
||||
.putString("mib_enc_otp_seed", encrypt(otpSeed, key))
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun loadMibCredentials(): MibCredentials? {
|
||||
val key = getOrCreateKey()
|
||||
val encUsername = prefs.getString("mib_enc_username", null) ?: return null
|
||||
val encHash = prefs.getString("mib_enc_password_hash", null) ?: return null
|
||||
val encSeed = prefs.getString("mib_enc_otp_seed", null) ?: return null
|
||||
return try {
|
||||
MibCredentials(
|
||||
decrypt(encUsername, key),
|
||||
decrypt(encHash, key),
|
||||
decrypt(encSeed, key)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMibCredentials() {
|
||||
prefs.edit()
|
||||
.remove("mib_enc_username")
|
||||
.remove("mib_enc_password_hash")
|
||||
.remove("mib_enc_otp_seed")
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun getOrCreateKey(): SecretKey {
|
||||
val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
|
||||
ks.getKey(keyAlias, null)?.let { return it as SecretKey }
|
||||
|
||||
val spec = KeyGenParameterSpec.Builder(
|
||||
keyAlias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.build()
|
||||
|
||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||
.also { it.init(spec) }
|
||||
.generateKey()
|
||||
}
|
||||
|
||||
private fun encrypt(plaintext: String, key: SecretKey): String {
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
val iv = cipher.iv
|
||||
val ct = cipher.doFinal(plaintext.toByteArray())
|
||||
return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" + Base64.encodeToString(ct, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
private fun decrypt(encoded: String, key: SecretKey): String {
|
||||
val parts = encoded.split(":")
|
||||
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
|
||||
val ct = Base64.decode(parts[1], Base64.NO_WRAP)
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
|
||||
return String(cipher.doFinal(ct))
|
||||
}
|
||||
}
|
||||
122
app/src/main/res/layout/activity_lock.xml
Normal file
122
app/src/main/res/layout/activity_lock.xml
Normal file
@@ -0,0 +1,122 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<!-- PIN unlock -->
|
||||
<LinearLayout
|
||||
android:id="@+id/viewPin"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="48dp"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/unlock_app"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/unlock_pin_subtitle"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvLockPinDots"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:textSize="26sp"
|
||||
android:letterSpacing="0.3"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
android:layout_marginBottom="20dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/lockNumpadContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnLockBiometricFromPin"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/use_biometrics"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Pattern unlock -->
|
||||
<LinearLayout
|
||||
android:id="@+id/viewPattern"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="48dp"
|
||||
android:paddingHorizontal="32dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/unlock_app"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/unlock_pattern_subtitle"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPatternHint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<sh.sar.basedbank.ui.onboarding.PatternView
|
||||
android:id="@+id/lockPatternView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnLockBiometricFromPattern"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/use_biometrics"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
@@ -18,4 +18,29 @@
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/autoLoginGroup"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<ProgressBar
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/signing_in"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
311
app/src/main/res/layout/fragment_security_setup.xml
Normal file
311
app/src/main/res/layout/fragment_security_setup.xml
Normal file
@@ -0,0 +1,311 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<!-- Step: Choose method -->
|
||||
<ScrollView
|
||||
android:id="@+id/viewChooseMethod"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingTop="40dp"
|
||||
android:paddingBottom="24dp"
|
||||
android:gravity="center_horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🔒"
|
||||
android:textSize="56sp"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/security_setup"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/security_setup_desc"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="40dp" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardPin"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:strokeWidth="1dp"
|
||||
app:strokeColor="?attr/colorOutline">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="20dp"
|
||||
android:gravity="center_vertical">
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/method_pin"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/method_pin_desc"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
</LinearLayout>
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="›"
|
||||
android:textSize="24sp"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardPattern"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:strokeWidth="1dp"
|
||||
app:strokeColor="?attr/colorOutline">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="20dp"
|
||||
android:gravity="center_vertical">
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/method_pattern"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/method_pattern_desc"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
</LinearLayout>
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="›"
|
||||
android:textSize="24sp"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<!-- Step: PIN entry / confirmation -->
|
||||
<LinearLayout
|
||||
android:id="@+id/viewPinSetup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="32dp"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPinTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/enter_pin"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/pin_min_digits"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="20dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPinDots"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:textSize="26sp"
|
||||
android:letterSpacing="0.3"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
android:layout_marginBottom="20dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/numpadContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnPinBack"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/back" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Step: Pattern entry / confirmation -->
|
||||
<LinearLayout
|
||||
android:id="@+id/viewPatternSetup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="32dp"
|
||||
android:paddingHorizontal="32dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPatternTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/draw_pattern"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPatternStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/pattern_min_dots"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<sh.sar.basedbank.ui.onboarding.PatternView
|
||||
android:id="@+id/patternView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnPatternBack"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/back" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Step: Biometric prompt -->
|
||||
<LinearLayout
|
||||
android:id="@+id/viewBiometric"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingTop="40dp"
|
||||
android:paddingBottom="24dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🔐"
|
||||
android:textSize="72sp"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/biometric_title"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/biometric_desc"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="32dp"
|
||||
app:cardBackgroundColor="?attr/colorSecondaryContainer"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="14dp"
|
||||
android:text="@string/biometric_security_note"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
android:gravity="center" />
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnEnableBiometrics"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:text="@string/enable_biometrics"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnSkipBiometrics"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/skip_biometrics" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</FrameLayout>
|
||||
@@ -28,6 +28,40 @@
|
||||
<string name="otp_seed_hint">ތިޔަ އޮތެންޓިކޭޓަ ދިން Base32 ސިއްރު</string>
|
||||
<string name="login">ލޮގިން</string>
|
||||
|
||||
<!-- Lock screen -->
|
||||
<string name="unlock_app">BasedBank ހުޅުވާ</string>
|
||||
<string name="unlock_pin_subtitle">PIN ޖަހާ</string>
|
||||
<string name="unlock_pattern_subtitle">ހުޅުވާ ޕެޓަން ކަހާ</string>
|
||||
<string name="use_biometrics">ބަޔޮމެޓްރިކް ބޭނުން ކުރޭ</string>
|
||||
<string name="biometric_prompt_subtitle">ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވާ</string>
|
||||
<string name="biometric_negative_btn">PIN / ޕެޓަން ބޭނުން ކުރޭ</string>
|
||||
<string name="unlock_failed">ދިމައެއް ނުވި — އަލުން ކަނޑޭ</string>
|
||||
|
||||
<!-- Security setup -->
|
||||
<string name="security_setup">އެޕް ރައްކާތެރި ކުރޭ</string>
|
||||
<string name="security_setup_desc">BasedBank ހުޅުވަން ބޭނުންވާ ގޮތެއް ހިޔާރު ކުރޭ.</string>
|
||||
<string name="method_pin">PIN ކޯޑް</string>
|
||||
<string name="method_pin_desc">4–8 ރިޔަލެއްގެ ނަންބަރު PIN</string>
|
||||
<string name="method_pattern">ޕެޓަން ކަހާ</string>
|
||||
<string name="method_pattern_desc">4 ނުވަތަ އެއަށްވުރެ ގިނަ ތިކި ގުޅުވާ</string>
|
||||
<string name="enter_pin">PIN ޖަހާ</string>
|
||||
<string name="confirm_pin">PIN ކަށަވަރު ކުރޭ</string>
|
||||
<string name="pin_min_digits">މަދުވެގެން 4 ރިޔަލ، ގިނަވެގެން 8</string>
|
||||
<string name="pin_no_match">PIN ދިމައެއް ނުވި — އަލުން ކަނޑޭ</string>
|
||||
<string name="draw_pattern">ޕެޓަން ކަހާ</string>
|
||||
<string name="confirm_pattern">ޕެޓަން ކަށަވަރު ކުރޭ</string>
|
||||
<string name="pattern_min_dots">މަދުވެގެން 4 ތިކި ގުޅުވާ</string>
|
||||
<string name="pattern_draw_again">އެ ޕެޓަން އަލުން ކަހާ</string>
|
||||
<string name="pattern_no_match">ޕެޓަން ދިމައެއް ނުވި — އަލުން ކަހާ</string>
|
||||
<string name="biometric_title">ބަޔޮމެޓްރިކް ބޭނުން ކުރަންތަ؟</string>
|
||||
<string name="biometric_desc">ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވޭ.</string>
|
||||
<string name="biometric_security_note">ބަޔޮމެޓްރިކް ފަސޭހަ ނަމަވެސް PIN ނުވަތަ ޕެޓަނަށްވުރެ ތަންކޮޅެއް ކަށަވަރެއް ނޫން. ރައްކާތެރިކަން ބޭނުން ނަމަ PIN ނުވަތަ ޕެޓަން ހިޔާރު ކުރޭ.</string>
|
||||
<string name="enable_biometrics">ބަޔޮމެޓްރިކް ހިންގާ</string>
|
||||
<string name="skip_biometrics">ސްކިޕް — PIN/ޕެޓަން ބޭނުން ކުރޭ</string>
|
||||
<string name="back">ފަހަތަށް</string>
|
||||
|
||||
<string name="signing_in">ލޮގިން ވަނީ…</string>
|
||||
|
||||
<!-- Home -->
|
||||
<string name="accounts">އެކައުންޓްތައް</string>
|
||||
<string name="available_balance">ލިބެން ހުރި ބެލެންސް</string>
|
||||
|
||||
@@ -27,6 +27,40 @@
|
||||
<string name="otp_seed_hint">The Base32 secret from your authenticator setup</string>
|
||||
<string name="login">Login</string>
|
||||
|
||||
<!-- Lock screen -->
|
||||
<string name="unlock_app">Unlock BasedBank</string>
|
||||
<string name="unlock_pin_subtitle">Enter your PIN</string>
|
||||
<string name="unlock_pattern_subtitle">Draw your unlock pattern</string>
|
||||
<string name="use_biometrics">Use Biometrics</string>
|
||||
<string name="biometric_prompt_subtitle">Use your fingerprint or face to unlock</string>
|
||||
<string name="biometric_negative_btn">Use PIN / Pattern</string>
|
||||
<string name="unlock_failed">Incorrect — try again</string>
|
||||
|
||||
<!-- Security setup -->
|
||||
<string name="security_setup">Secure Your App</string>
|
||||
<string name="security_setup_desc">Choose how you want to lock BasedBank when you\'re away.</string>
|
||||
<string name="method_pin">PIN Code</string>
|
||||
<string name="method_pin_desc">4–8 digit numeric PIN</string>
|
||||
<string name="method_pattern">Draw Pattern</string>
|
||||
<string name="method_pattern_desc">Connect 4 or more dots in a pattern</string>
|
||||
<string name="enter_pin">Set Your PIN</string>
|
||||
<string name="confirm_pin">Confirm Your PIN</string>
|
||||
<string name="pin_min_digits">Minimum 4 digits, maximum 8</string>
|
||||
<string name="pin_no_match">PINs don\'t match — try again</string>
|
||||
<string name="draw_pattern">Draw Your Pattern</string>
|
||||
<string name="confirm_pattern">Confirm Your Pattern</string>
|
||||
<string name="pattern_min_dots">Connect at least 4 dots</string>
|
||||
<string name="pattern_draw_again">Draw the same pattern again</string>
|
||||
<string name="pattern_no_match">Patterns don\'t match — try again</string>
|
||||
<string name="biometric_title">Use Biometrics?</string>
|
||||
<string name="biometric_desc">Unlock with your fingerprint or face scan instead of your PIN or pattern.</string>
|
||||
<string name="biometric_security_note">Biometrics is convenient but slightly less secure than a PIN or pattern. For maximum security, use PIN or pattern only.</string>
|
||||
<string name="enable_biometrics">Enable Biometrics</string>
|
||||
<string name="skip_biometrics">Skip — use PIN/Pattern only</string>
|
||||
<string name="back">Back</string>
|
||||
|
||||
<string name="signing_in">Signing in…</string>
|
||||
|
||||
<!-- Home -->
|
||||
<string name="accounts">Accounts</string>
|
||||
<string name="available_balance">Available Balance</string>
|
||||
|
||||
Reference in New Issue
Block a user