9 Commits
Author SHA1 Message Date
shihaam 89a9731797 new version
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 4m24s
2026-05-19 00:15:19 +05:00
shihaam 50badc7d54 support for edge to edge
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 00:13:42 +05:00
shihaam d4f86bb738 optimize OTP seed (check password =) remove legacy code
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-18 23:57:05 +05:00
shihaam b35f44f35b optimize bank login screens, add support for otpauth://totp/ URI
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 23:49:42 +05:00
shihaam 1a58ce8b54 disable welcome screen page switch with dots
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-18 23:36:31 +05:00
shihaam 2dd84ec50a countdown now starts after scoll
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 23:30:21 +05:00
shihaam 33651ca107 redesign the welcome pages
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-18 23:12:51 +05:00
shihaam ae307e3118 add biometrics disabled hint
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-18 21:46:34 +05:00
shihaam 3ab75bff92 fix contact add with multi BML logins
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 05:52:18 +05:00
29 changed files with 817 additions and 320 deletions
+15 -2
View File
@@ -3,7 +3,7 @@
<component name="deploymentTargetSelector"> <component name="deploymentTargetSelector">
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DIALOG" />
<DropdownSelection timestamp="2026-05-15T13:54:16.798188666Z"> <DropdownSelection timestamp="2026-05-15T13:54:16.798188666Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
@@ -11,7 +11,20 @@
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>
<DialogSelection /> <DialogSelection>
<targets>
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
</handle>
</Target>
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
</handle>
</Target>
</targets>
</DialogSelection>
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 2
versionName = "1.0" versionName = "1.0.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -10,6 +10,9 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat 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 androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -18,8 +21,6 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.databinding.ActivityLockBinding import sh.sar.basedbank.databinding.ActivityLockBinding
import sh.sar.basedbank.ui.home.HomeActivity import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import java.security.MessageDigest
import java.security.SecureRandom
import javax.crypto.SecretKeyFactory import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.PBEKeySpec
@@ -31,7 +32,6 @@ class LockActivity : AppCompatActivity() {
private lateinit var salt: String private lateinit var salt: String
private lateinit var storedHash: String private lateinit var storedHash: String
private var biometricsEnabled = false private var biometricsEnabled = false
private var isLegacyFormat = false
private var isVerifying = false private var isVerifying = false
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE) private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
@@ -44,24 +44,27 @@ class LockActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLockBinding.inflate(layoutInflater) binding = ActivityLockBinding.inflate(layoutInflater)
setContentView(binding.root) 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) val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
method = prefs.getString("security_method", "pin") ?: "pin" method = prefs.getString("security_method", "pin") ?: "pin"
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false) biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
// Try new encrypted format first; fall back to legacy SHA-256 val stored = CredentialStore(this).loadSecurityHash() ?: run { finish(); return }
val stored = CredentialStore(this).loadSecurityHash() salt = stored.first
if (stored != null) { storedHash = stored.second
salt = stored.first
storedHash = stored.second
isLegacyFormat = false
} else {
salt = prefs.getString("security_salt", "") ?: ""
storedHash = prefs.getString("security_hash", "") ?: ""
isLegacyFormat = true
}
if (method == "pin") { if (method == "pin") {
binding.viewPin.visibility = View.VISIBLE binding.viewPin.visibility = View.VISIBLE
@@ -150,7 +153,6 @@ class LockActivity : AppCompatActivity() {
val ok = withContext(Dispatchers.Default) { verify(entered) } val ok = withContext(Dispatchers.Default) { verify(entered) }
isVerifying = false isVerifying = false
if (ok) { if (ok) {
migrateIfNeeded(entered)
resetFailures() resetFailures()
proceed() proceed()
} else { } else {
@@ -175,7 +177,6 @@ class LockActivity : AppCompatActivity() {
val ok = withContext(Dispatchers.Default) { verify(entered) } val ok = withContext(Dispatchers.Default) { verify(entered) }
isVerifying = false isVerifying = false
if (ok) { if (ok) {
migrateIfNeeded(entered)
resetFailures() resetFailures()
proceed() proceed()
} else { } else {
@@ -217,30 +218,8 @@ class LockActivity : AppCompatActivity() {
private fun verify(input: String): Boolean { private fun verify(input: String): Boolean {
if (storedHash.isBlank()) return false if (storedHash.isBlank()) return false
return if (isLegacyFormat) { val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
sha256Legacy(salt + input) == storedHash return pbkdf2(input, saltBytes) == 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() { private fun triggerBiometric() {
@@ -313,9 +292,4 @@ class LockActivity : AppCompatActivity() {
} }
} }
/** 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) }
} }
@@ -12,6 +12,9 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Filter
import android.widget.Filterable
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -20,6 +23,7 @@ import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import sh.sar.basedbank.util.ContactsCache import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.CredentialStore
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -45,7 +49,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private data class DestinationOption( private data class DestinationOption(
val label: String, val label: String,
val isBml: Boolean, val isBml: Boolean,
val mibProfile: MibProfile? = null val mibProfile: MibProfile? = null,
val bmlLoginId: String? = null,
val subtitle: String = ""
) )
private var destinations: List<DestinationOption> = emptyList() private var destinations: List<DestinationOption> = emptyList()
@@ -88,18 +94,44 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
for (profile in app.mibProfiles) { for (profile in app.mibProfiles) {
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile)) list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile))
} }
if (app.anyBmlSession() != null) { val store = CredentialStore(requireContext())
list.add(DestinationOption("BML · Personal", isBml = true)) for ((loginId, _) in app.bmlSessions) {
val ownerName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() } ?: loginId
val profileName = app.bmlAccounts.firstOrNull { it.loginTag == "bml_$loginId" }?.profileName ?: ""
list.add(DestinationOption("BML · $ownerName", isBml = true, bmlLoginId = loginId, subtitle = profileName))
} }
return list return list
} }
private fun setupDestinationDropdown() { private fun setupDestinationDropdown() {
val labels = destinations.map { it.label } val adapter = object : ArrayAdapter<DestinationOption>(requireContext(), android.R.layout.simple_list_item_2, destinations) {
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, labels) override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
getDropDownView(position, convertView, parent)
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_2, parent, false)
val opt = destinations[position]
view.findViewById<TextView>(android.R.id.text1).text = opt.label
val text2 = view.findViewById<TextView>(android.R.id.text2)
if (opt.subtitle.isNotBlank()) {
text2.text = opt.subtitle
text2.visibility = View.VISIBLE
} else {
text2.visibility = View.GONE
}
return view
}
override fun getFilter() = object : Filter() {
override fun performFiltering(c: CharSequence?) = FilterResults().apply { values = destinations; count = destinations.size }
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
override fun convertResultToString(r: Any?) = ""
}
}
binding.actvDestination.setAdapter(adapter) binding.actvDestination.setAdapter(adapter)
binding.actvDestination.setOnItemClickListener { _, _, position, _ -> binding.actvDestination.setOnItemClickListener { _, _, position, _ ->
selectedDest = destinations[position] selectedDest = destinations[position]
binding.actvDestination.setText(destinations[position].label, false)
clearLookupResult() clearLookupResult()
updateMibOnlyVisibility() updateMibOnlyVisibility()
binding.btnSave.isEnabled = false binding.btnSave.isEnabled = false
@@ -204,7 +236,8 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} }
private fun lookupForBml(input: String): BmlAccountValidation? { private fun lookupForBml(input: String): BmlAccountValidation? {
val bmlSess = app.anyBmlSession() ?: return null val loginId = selectedDest?.bmlLoginId ?: return null
val bmlSess = app.bmlSessions[loginId] ?: return null
val bmlFlow = BmlLoginFlow() val bmlFlow = BmlLoginFlow()
// 1) Try BML validate // 1) Try BML validate
@@ -378,7 +411,8 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} }
private fun saveToBml(alias: String): Boolean { private fun saveToBml(alias: String): Boolean {
val bmlSess = app.anyBmlSession() ?: return false val loginId = selectedDest?.bmlLoginId ?: return false
val bmlSess = app.bmlSessions[loginId] ?: return false
val lookup = bmlLookup ?: return false val lookup = bmlLookup ?: return false
val bmlFlow = BmlLoginFlow() val bmlFlow = BmlLoginFlow()
val account = lookup.account val account = lookup.account
@@ -445,8 +479,8 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
requireActivity().lifecycleScope.launch(Dispatchers.IO) { requireActivity().lifecycleScope.launch(Dispatchers.IO) {
try { try {
if (dest.isBml) { if (dest.isBml) {
val bmlSess = app.anyBmlSession() ?: return@launch val loginId = dest.bmlLoginId ?: return@launch
val loginId = app.bmlSessions.entries.firstOrNull { it.value == bmlSess }?.key ?: "" val bmlSess = app.bmlSessions[loginId] ?: return@launch
val fresh = BmlLoginFlow().fetchContacts(bmlSess, loginId) val fresh = BmlLoginFlow().fetchContacts(bmlSess, loginId)
val existing = viewModel.contacts.value ?: emptyList() val existing = viewModel.contacts.value ?: emptyList()
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
@@ -1,6 +1,7 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.os.CountDownTimer import android.os.CountDownTimer
import android.os.Handler import android.os.Handler
@@ -14,6 +15,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -84,11 +86,17 @@ class HomeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityHomeBinding.inflate(layoutInflater) binding = ActivityHomeBinding.inflate(layoutInflater)
if (getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("block_screenshots", true)) { if (getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("block_screenshots", true)) {
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE) window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
} }
setContentView(binding.root) setContentView(binding.root)
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
setSupportActionBar(binding.toolbar) setSupportActionBar(binding.toolbar)
toggle = ActionBarDrawerToggle( toggle = ActionBarDrawerToggle(
@@ -14,6 +14,9 @@ import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.core.resolutionselector.AspectRatioStrategy import androidx.camera.core.resolutionselector.AspectRatioStrategy
@@ -84,8 +87,23 @@ class QrScannerActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityQrScannerBinding.inflate(layoutInflater) binding = ActivityQrScannerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
// Black camera background — always use light (white) system bar icons
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = false
isAppearanceLightNavigationBars = false
}
val originalBtnMarginBottom = (48 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.btnContainer) { view, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
(view.layoutParams as android.widget.FrameLayout.LayoutParams).also {
it.bottomMargin = originalBtnMarginBottom + bars.bottom
view.layoutParams = it
}
insets
}
binding.btnCancel.setOnClickListener { finish() } binding.btnCancel.setOnClickListener { finish() }
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") } binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
@@ -35,8 +35,6 @@ class SettingsSecurityFragment : Fragment() {
val canUseBiometrics = BiometricManager.from(requireContext()) val canUseBiometrics = BiometricManager.from(requireContext())
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
if (canUseBiometrics) { if (canUseBiometrics) {
binding.rowBiometrics.visibility = View.VISIBLE
val unlockEnabled = prefs.getBoolean("biometrics_enabled", false) val unlockEnabled = prefs.getBoolean("biometrics_enabled", false)
binding.switchBiometrics.isChecked = unlockEnabled binding.switchBiometrics.isChecked = unlockEnabled
binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false) binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false)
@@ -54,6 +52,10 @@ class SettingsSecurityFragment : Fragment() {
binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked -> binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply() prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply()
} }
} else {
binding.tvBiometricsHint.visibility = View.VISIBLE
binding.switchBiometrics.isEnabled = false
binding.switchBiometricsTransfer.isEnabled = false
} }
// Auto-lock // Auto-lock
@@ -1009,7 +1009,9 @@ class TransferFragment : Fragment() {
.also { it.root.tag = it } .also { it.root.tag = it }
} }
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true) val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
b.tvDropdownAccountName.text = acc.accountBriefName val isBmlAccount = acc.profileType.startsWith("BML")
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}" b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
b.root.alpha = if (inactive) 0.4f else 1f b.root.alpha = if (inactive) 0.4f else 1f
@@ -67,11 +67,27 @@ class CredentialsFragment : Fragment() {
} }
} }
binding.btnLogin.isEnabled = false
binding.btnLogin.setOnClickListener { attemptLogin() } binding.btnLogin.setOnClickListener { attemptLogin() }
val loginFieldWatcher = object : TextWatcher {
override fun afterTextChanged(s: Editable?) { updateLoginButtonState() }
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
}
binding.etUsername.addTextChangedListener(loginFieldWatcher)
binding.etPassword.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) { updateLoginButtonState(); updateOtpDisplay() }
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
})
if (bankType != "FAHIPAY") { if (bankType != "FAHIPAY") {
binding.etOtpSeed.addTextChangedListener(object : TextWatcher { binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) { updateOtpDisplay() } override fun afterTextChanged(s: Editable?) {
updateOtpDisplay()
updateLoginButtonState()
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
}) })
@@ -88,10 +104,35 @@ class CredentialsFragment : Fragment() {
otpHandler.removeCallbacks(otpRunnable) otpHandler.removeCallbacks(otpRunnable)
} }
private fun resolveOtpSeed(input: String): String {
val secret = if (input.startsWith("otpauth://totp/"))
android.net.Uri.parse(input).getQueryParameter("secret") ?: input
else
input
return secret.replace("\\s".toRegex(), "").replace("-", "").uppercase()
}
private fun updateLoginButtonState() {
val username = binding.etUsername.text.toString().trim()
val password = binding.etPassword.text.toString()
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
val otpSeed = resolveOtpSeed(otpSeedRaw)
binding.btnLogin.isEnabled = when (bankType) {
"FAHIPAY" -> username.isNotEmpty() && password.isNotEmpty()
else -> username.isNotEmpty() && password.isNotEmpty() && otpSeed.isNotEmpty() && password != otpSeedRaw
}
}
private fun updateOtpDisplay() { private fun updateOtpDisplay() {
val seed = binding.etOtpSeed.text.toString().trim() val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
val seed = resolveOtpSeed(otpSeedRaw)
if (seed.isEmpty()) { if (seed.isEmpty()) {
binding.cardOtp.visibility = View.GONE binding.cardOtp.visibility = View.INVISIBLE
return
}
val password = binding.etPassword.text.toString()
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
binding.cardOtp.visibility = View.INVISIBLE
return return
} }
try { try {
@@ -104,7 +145,7 @@ class CredentialsFragment : Fragment() {
binding.otpTimer.progress = remaining binding.otpTimer.progress = remaining
binding.cardOtp.visibility = View.VISIBLE binding.cardOtp.visibility = View.VISIBLE
} catch (e: Exception) { } catch (e: Exception) {
binding.cardOtp.visibility = View.GONE binding.cardOtp.visibility = View.INVISIBLE
} }
} }
@@ -116,7 +157,7 @@ class CredentialsFragment : Fragment() {
val username = binding.etUsername.text.toString().trim() val username = binding.etUsername.text.toString().trim()
val password = binding.etPassword.text.toString() val password = binding.etPassword.text.toString()
val otpSeed = binding.etOtpSeed.text.toString().trim() val otpSeed = resolveOtpSeed(binding.etOtpSeed.text.toString().trim())
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) { if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
binding.tvError.text = "Please fill in all fields" binding.tvError.text = "Please fill in all fields"
@@ -173,7 +214,7 @@ class CredentialsFragment : Fragment() {
private fun attemptBmlLogin() { private fun attemptBmlLogin() {
val username = binding.etUsername.text.toString().trim() val username = binding.etUsername.text.toString().trim()
val password = binding.etPassword.text.toString() val password = binding.etPassword.text.toString()
val otpSeed = binding.etOtpSeed.text.toString().trim() val otpSeed = resolveOtpSeed(binding.etOtpSeed.text.toString().trim())
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) { if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
binding.tvError.text = "Please fill in all fields" binding.tvError.text = "Please fill in all fields"
@@ -1,7 +1,9 @@
package sh.sar.basedbank.ui.login package sh.sar.basedbank.ui.login
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import sh.sar.basedbank.databinding.ActivityLoginBinding import sh.sar.basedbank.databinding.ActivityLoginBinding
class LoginActivity : AppCompatActivity() { class LoginActivity : AppCompatActivity() {
@@ -10,7 +12,13 @@ class LoginActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLoginBinding.inflate(layoutInflater) binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
} }
} }
@@ -3,10 +3,14 @@ package sh.sar.basedbank.ui.onboarding
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.os.CountDownTimer
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import sh.sar.basedbank.R import sh.sar.basedbank.R
@@ -17,46 +21,68 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
private lateinit var binding: ActivityOnboardingBinding private lateinit var binding: ActivityOnboardingBinding
private lateinit var prefs: SharedPreferences private lateinit var prefs: SharedPreferences
private var countDownTimer: CountDownTimer? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityOnboardingBinding.inflate(layoutInflater) binding = ActivityOnboardingBinding.inflate(layoutInflater)
setContentView(binding.root) 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
}
prefs = getSharedPreferences("prefs", MODE_PRIVATE) prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val originalBottomPadding = binding.bottomBar.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, originalBottomPadding + navBar.bottom)
insets
}
val adapter = OnboardingPagerAdapter(this) val adapter = OnboardingPagerAdapter(this)
binding.viewPager.adapter = adapter binding.viewPager.adapter = adapter
TabLayoutMediator(binding.dotsIndicator, binding.viewPager) { _, _ -> }.attach() TabLayoutMediator(binding.dotsIndicator, binding.viewPager) { _, _ -> }.attach()
// Disable tap-to-navigate on dots: touch listener must be on the individual
// Pre-select language chip without triggering the listener // tab views inside SlidingTabStrip (child 0), because they consume ACTION_DOWN
val savedLang = prefs.getString("language", null) // before the TabLayout's own touch listener ever fires.
binding.languageChipGroup.setOnCheckedStateChangeListener(null) val tabStrip = binding.dotsIndicator.getChildAt(0) as? android.view.ViewGroup
when (savedLang) { tabStrip?.let {
"en" -> binding.chipEnglish.isChecked = true for (i in 0 until it.childCount) {
"dv" -> binding.chipDhivehi.isChecked = true it.getChildAt(i).setOnTouchListener { _, _ -> true }
}
binding.languageChipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
if (checkedIds.isNotEmpty()) {
selectLanguage(if (checkedIds[0] == R.id.chipEnglish) "en" else "dv")
} }
} }
// Pre-select language button without triggering the listener
val savedLang = prefs.getString("language", null)
binding.languageToggle.clearOnButtonCheckedListeners()
when (savedLang) {
"en" -> binding.btnLangEnglish.isChecked = true
"dv" -> binding.btnLangDhivehi.isChecked = true
}
binding.languageToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (isChecked) selectLanguage(if (checkedId == R.id.btnLangEnglish) "en" else "dv")
}
supportFragmentManager.setFragmentResultListener(OnboardingFragment.RESULT_SCROLLED_TO_BOTTOM, this) { _, _ ->
startGetStartedCountdown()
}
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) { override fun onPageSelected(position: Int) {
binding.languageChipGroup.visibility = if (position == 0) View.VISIBLE else View.GONE binding.languageSection.visibility = if (position == 0) View.VISIBLE else View.GONE
// Block forward swipe on slide 1 until security is set up binding.viewPager.isUserInputEnabled = when {
if (position == 1) { position > 2 -> false
binding.viewPager.isUserInputEnabled = position == 1 -> prefs.getString("security_method", null) != null
prefs.getString("security_method", null) != null else -> true
} else {
binding.viewPager.isUserInputEnabled = true
} }
updateButtons(position, adapter.itemCount) updateButtons(position, adapter.itemCount)
} }
}) })
binding.languageChipGroup.visibility = View.VISIBLE binding.languageSection.visibility = View.VISIBLE
updateButtons(0, adapter.itemCount) updateButtons(0, adapter.itemCount)
binding.btnNext.setOnClickListener { binding.btnNext.setOnClickListener {
@@ -71,10 +97,21 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
} }
} }
override fun onDestroy() {
super.onDestroy()
countDownTimer?.cancel()
}
// Called by SecuritySetupFragment when setup is complete // Called by SecuritySetupFragment when setup is complete
override fun onSecuritySetupComplete() { override fun onSecuritySetupComplete() {
binding.viewPager.isUserInputEnabled = true binding.viewPager.isUserInputEnabled = true
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3) updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 4)
}
// Called by SecuritySetupFragment when user resets to reconfigure
override fun onSecuritySetupReset() {
binding.viewPager.isUserInputEnabled = false
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 4)
} }
private fun selectLanguage(lang: String) { private fun selectLanguage(lang: String) {
@@ -83,23 +120,34 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3) updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3)
} }
private fun startGetStartedCountdown() {
binding.btnGetStarted.isEnabled = false
countDownTimer?.cancel()
countDownTimer = object : CountDownTimer(5000, 1000) {
override fun onTick(millisUntilFinished: Long) {
val seconds = (millisUntilFinished / 1000 + 1).toInt()
binding.btnGetStarted.text = "${getString(R.string.get_started)} ($seconds)"
}
override fun onFinish() {
binding.btnGetStarted.text = getString(R.string.get_started)
binding.btnGetStarted.isEnabled = true
}
}.start()
}
private fun updateButtons(position: Int, count: Int) { private fun updateButtons(position: Int, count: Int) {
val langSelected = prefs.getString("language", null) != null val langSelected = prefs.getString("language", null) != null
val securityDone = prefs.getString("security_method", null) != null val securityDone = prefs.getString("security_method", null) != null
val isLast = position == count - 1 val isLast = position == count - 1
binding.btnGetStarted.visibility = if (isLast) View.VISIBLE else View.GONE binding.btnGetStarted.visibility = if (isLast) View.VISIBLE else View.GONE
if (isLast) binding.btnGetStarted.isEnabled = false
// Hide Next on slide 1 until security is done (avoids a disabled-button-with-no-explanation) binding.btnNext.visibility = if (isLast) View.GONE else View.VISIBLE
binding.btnNext.visibility = when {
isLast -> View.GONE
position == 1 && !securityDone -> View.GONE
else -> View.VISIBLE
}
binding.btnNext.isEnabled = when (position) { binding.btnNext.isEnabled = when (position) {
0 -> langSelected 0 -> langSelected
1 -> securityDone 1 -> securityDone
else -> true else -> true // position 2 (configure) has no gate
} }
} }
} }
@@ -0,0 +1,100 @@
package sh.sar.basedbank.ui.onboarding
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatDelegate
import androidx.biometric.BiometricManager
import androidx.fragment.app.Fragment
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding
class OnboardingConfigureFragment : Fragment() {
private var _binding: FragmentOnboardingConfigureBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentOnboardingConfigureBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
// Navigation — default Drawer
val isBottom = prefs.getBoolean("bottom_nav", false)
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
}
// Theme — default System
val savedTheme = prefs.getString("theme", "system")
binding.themeToggle.check(when (savedTheme) {
"light" -> R.id.btnThemeLight
"dark" -> R.id.btnThemeDark
else -> R.id.btnThemeSystem
})
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener
val (key, mode) = when (checkedId) {
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
else -> "system" to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
prefs.edit().putString("theme", key).apply()
AppCompatDelegate.setDefaultNightMode(mode)
}
// Biometrics
val canUseBiometrics = BiometricManager.from(requireContext())
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
if (canUseBiometrics) {
val unlockEnabled = prefs.getBoolean("biometrics_enabled", false)
binding.switchBiometrics.isChecked = unlockEnabled
binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false)
binding.switchBiometricsTransfer.isEnabled = unlockEnabled
binding.switchBiometrics.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("biometrics_enabled", isChecked).apply()
binding.switchBiometricsTransfer.isEnabled = isChecked
if (!isChecked) {
binding.switchBiometricsTransfer.isChecked = false
prefs.edit().putBoolean("biometrics_transfer_confirm", false).apply()
}
}
binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply()
}
} else {
binding.tvBiometricsHint.visibility = View.VISIBLE
binding.switchBiometrics.isEnabled = false
binding.switchBiometricsTransfer.isEnabled = false
}
// Block screenshots — default on
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
binding.switchBlockScreenshots.isChecked = blockScreenshots
applyFlagSecure(blockScreenshots)
binding.switchBlockScreenshots.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("block_screenshots", isChecked).apply()
applyFlagSecure(isChecked)
}
}
private fun applyFlagSecure(enabled: Boolean) {
val win = activity?.window ?: return
if (enabled) win.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
else win.clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
@@ -4,6 +4,8 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.ScrollView
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import sh.sar.basedbank.databinding.FragmentOnboardingSlideBinding import sh.sar.basedbank.databinding.FragmentOnboardingSlideBinding
@@ -12,6 +14,7 @@ class OnboardingFragment : Fragment() {
private var _binding: FragmentOnboardingSlideBinding? = null private var _binding: FragmentOnboardingSlideBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private var scrolledToBottom = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentOnboardingSlideBinding.inflate(inflater, container, false) _binding = FragmentOnboardingSlideBinding.inflate(inflater, container, false)
@@ -19,17 +22,45 @@ class OnboardingFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val title = requireArguments().getString(ARG_TITLE, "") val titleRes = requireArguments().getInt(ARG_TITLE)
val desc = requireArguments().getString(ARG_DESC, "") val descRes = requireArguments().getInt(ARG_DESC)
val icon = requireArguments().getInt(ARG_ICON, 0) val icon = requireArguments().getInt(ARG_ICON, 0)
val isFirst = requireArguments().getBoolean(ARG_IS_FIRST, false) val isFirst = requireArguments().getBoolean(ARG_IS_FIRST, false)
val isLast = requireArguments().getBoolean(ARG_IS_LAST, false)
binding.icon.visibility = if (isLast) View.GONE else View.VISIBLE
binding.icon.setImageResource(icon) binding.icon.setImageResource(icon)
binding.title.text = title binding.title.text = getString(titleRes)
binding.description.text = desc binding.description.text = getString(descRes)
binding.description.gravity = if (isLast) android.view.Gravity.START else android.view.Gravity.CENTER
// On the first slide, show the two placeholder cards for upcoming banks // On the first slide, show the two placeholder cards for upcoming banks
binding.placeholderCards.visibility = if (isFirst) View.VISIBLE else View.GONE binding.placeholderCards.visibility = if (isFirst) View.VISIBLE else View.GONE
if (isLast) setupScrollToBottomDetection()
}
private fun setupScrollToBottomDetection() {
val scrollView = binding.scrollView
// If content fits without scrolling, fire immediately after layout
scrollView.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
scrollView.viewTreeObserver.removeOnGlobalLayoutListener(this)
val child = scrollView.getChildAt(0) ?: return
if (child.height <= scrollView.height) notifyScrolledToBottom()
}
})
scrollView.setOnScrollChangeListener { v, _, scrollY, _, _ ->
val sv = v as ScrollView
val child = sv.getChildAt(0) ?: return@setOnScrollChangeListener
if (scrollY + sv.height >= child.height) notifyScrolledToBottom()
}
}
private fun notifyScrolledToBottom() {
if (scrolledToBottom) return
scrolledToBottom = true
parentFragmentManager.setFragmentResult(RESULT_SCROLLED_TO_BOTTOM, Bundle.EMPTY)
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -38,17 +69,20 @@ class OnboardingFragment : Fragment() {
} }
companion object { companion object {
const val RESULT_SCROLLED_TO_BOTTOM = "scroll_to_bottom"
private const val ARG_TITLE = "title" private const val ARG_TITLE = "title"
private const val ARG_DESC = "desc" private const val ARG_DESC = "desc"
private const val ARG_ICON = "icon" private const val ARG_ICON = "icon"
private const val ARG_IS_FIRST = "is_first" private const val ARG_IS_FIRST = "is_first"
private const val ARG_IS_LAST = "is_last"
fun newInstance(slide: OnboardingSlide) = OnboardingFragment().apply { fun newInstance(slide: OnboardingSlide) = OnboardingFragment().apply {
arguments = bundleOf( arguments = bundleOf(
ARG_TITLE to slide.title, ARG_TITLE to slide.titleRes,
ARG_DESC to slide.description, ARG_DESC to slide.descRes,
ARG_ICON to slide.iconRes, ARG_ICON to slide.iconRes,
ARG_IS_FIRST to slide.isFirst ARG_IS_FIRST to slide.isFirst,
ARG_IS_LAST to slide.isLast
) )
} }
} }
@@ -9,36 +9,39 @@ class OnboardingPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(
private val slides = listOf( private val slides = listOf(
OnboardingSlide( OnboardingSlide(
title = activity.getString(R.string.onboarding_title_1), titleRes = R.string.onboarding_title_1,
description = activity.getString(R.string.onboarding_desc_1), descRes = R.string.onboarding_desc_1,
iconRes = R.drawable.ic_launcher_foreground, iconRes = R.drawable.ic_launcher_foreground,
isFirst = true isFirst = true
), ),
OnboardingSlide( OnboardingSlide(
title = activity.getString(R.string.onboarding_title_2), titleRes = R.string.onboarding_title_2,
description = activity.getString(R.string.onboarding_desc_2), descRes = R.string.onboarding_desc_2,
iconRes = R.drawable.ic_launcher_foreground, iconRes = R.drawable.ic_launcher_foreground,
isFirst = false isFirst = false
), ),
OnboardingSlide( OnboardingSlide(
title = activity.getString(R.string.onboarding_title_3), titleRes = R.string.onboarding_title_3,
description = activity.getString(R.string.onboarding_desc_3), descRes = R.string.onboarding_desc_3,
iconRes = R.drawable.ic_launcher_foreground, iconRes = R.drawable.ic_launcher_foreground,
isFirst = false isFirst = false,
isLast = true
) )
) )
override fun getItemCount() = slides.size override fun getItemCount() = slides.size + 1 // +1 for OnboardingConfigureFragment at position 2
override fun createFragment(position: Int): Fragment = when (position) { override fun createFragment(position: Int): Fragment = when (position) {
1 -> SecuritySetupFragment() 1 -> SecuritySetupFragment()
else -> OnboardingFragment.newInstance(slides[position]) 2 -> OnboardingConfigureFragment()
else -> OnboardingFragment.newInstance(slides[position - if (position > 2) 1 else 0])
} }
} }
data class OnboardingSlide( data class OnboardingSlide(
val title: String, val titleRes: Int,
val description: String, val descRes: Int,
val iconRes: Int, val iconRes: Int,
val isFirst: Boolean val isFirst: Boolean,
val isLast: Boolean = false
) )
@@ -86,17 +86,21 @@ class PatternView @JvmOverloads constructor(
if (errorState) return false if (errorState) return false
when (event.action) { when (event.action) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
parent?.requestDisallowInterceptTouchEvent(true)
recording = true; selected.clear() recording = true; selected.clear()
hit(event.x, event.y) hit(event.x, event.y)
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
parent?.requestDisallowInterceptTouchEvent(true)
touchX = event.x; touchY = event.y touchX = event.x; touchY = event.y
hit(event.x, event.y) hit(event.x, event.y)
} }
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
parent?.requestDisallowInterceptTouchEvent(false)
recording = false recording = false
invalidate() invalidate()
onPatternComplete?.invoke(selected.map { it.index }) if (event.action == MotionEvent.ACTION_UP)
onPatternComplete?.invoke(selected.map { it.index })
} }
} }
invalidate() invalidate()
@@ -7,7 +7,6 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.biometric.BiometricManager
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import sh.sar.basedbank.R import sh.sar.basedbank.R
@@ -21,6 +20,7 @@ class SecuritySetupFragment : Fragment() {
interface Callback { interface Callback {
fun onSecuritySetupComplete() fun onSecuritySetupComplete()
fun onSecuritySetupReset()
} }
companion object { companion object {
@@ -33,7 +33,7 @@ class SecuritySetupFragment : Fragment() {
private var _b: FragmentSecuritySetupBinding? = null private var _b: FragmentSecuritySetupBinding? = null
private val b get() = _b!! private val b get() = _b!!
private enum class Step { CHOOSE, PIN_ENTER, PIN_CONFIRM, PATTERN_ENTER, PATTERN_CONFIRM, BIOMETRIC } private enum class Step { CONFIGURED, CHOOSE, PIN_ENTER, PIN_CONFIRM, PATTERN_ENTER, PATTERN_CONFIRM }
private var step = Step.CHOOSE private var step = Step.CHOOSE
private val pinDigits = mutableListOf<Int>() private val pinDigits = mutableListOf<Int>()
@@ -48,9 +48,6 @@ class SecuritySetupFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val changeMode = arguments?.getBoolean(ARG_CHANGE_MODE, false) ?: false val changeMode = arguments?.getBoolean(ARG_CHANGE_MODE, false) ?: false
if (!changeMode && prefs.getString("security_method", null) != null) {
(activity as? Callback)?.onSecuritySetupComplete()
}
b.cardPin.setOnClickListener { goTo(Step.PIN_ENTER) } b.cardPin.setOnClickListener { goTo(Step.PIN_ENTER) }
b.cardPattern.setOnClickListener { goTo(Step.PATTERN_ENTER) } b.cardPattern.setOnClickListener { goTo(Step.PATTERN_ENTER) }
@@ -62,20 +59,21 @@ class SecuritySetupFragment : Fragment() {
} }
} }
b.btnPatternBack.setOnClickListener { goTo(Step.CHOOSE) } b.btnPatternBack.setOnClickListener { goTo(Step.CHOOSE) }
b.btnChangeLock.setOnClickListener {
prefs.edit().remove("security_method").apply()
(activity as? Callback)?.onSecuritySetupReset()
goTo(Step.CHOOSE)
}
b.patternView.onPatternComplete = { pattern -> handlePattern(pattern) } 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() buildNumpad()
goTo(Step.CHOOSE)
if (!changeMode && prefs.getString("security_method", null) != null) {
goTo(Step.CONFIGURED)
} else {
goTo(Step.CHOOSE)
}
} }
private fun buildNumpad() { private fun buildNumpad() {
@@ -144,7 +142,7 @@ class SecuritySetupFragment : Fragment() {
Step.PIN_CONFIRM -> { Step.PIN_CONFIRM -> {
if (entered == firstPin) { if (entered == firstPin) {
saveCredential("pin", entered) saveCredential("pin", entered)
goToBiometricOrFinish() finishSetup()
} else { } else {
b.tvPinDots.text = getString(R.string.pin_no_match) b.tvPinDots.text = getString(R.string.pin_no_match)
pinDigits.clear() pinDigits.clear()
@@ -172,7 +170,7 @@ class SecuritySetupFragment : Fragment() {
Step.PATTERN_CONFIRM -> { Step.PATTERN_CONFIRM -> {
if (pattern == firstPattern) { if (pattern == firstPattern) {
saveCredential("pattern", pattern.joinToString("")) saveCredential("pattern", pattern.joinToString(""))
goToBiometricOrFinish() finishSetup()
} else { } else {
b.patternView.showError() b.patternView.showError()
b.tvPatternStatus.text = getString(R.string.pattern_no_match) b.tvPatternStatus.text = getString(R.string.pattern_no_match)
@@ -187,29 +185,12 @@ class SecuritySetupFragment : Fragment() {
} }
} }
private fun goToBiometricOrFinish() {
// In change mode, biometrics is managed from Settings — skip this step
if (arguments?.getBoolean(ARG_CHANGE_MODE, false) == true) {
finishSetup()
return
}
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) { private fun goTo(s: Step) {
step = s step = s
b.viewConfigured.visibility = if (s == Step.CONFIGURED) View.VISIBLE else View.GONE
b.viewChooseMethod.visibility = if (s == Step.CHOOSE) View.VISIBLE else View.GONE 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.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.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) { when (s) {
Step.PIN_ENTER -> { Step.PIN_ENTER -> {
@@ -236,9 +217,6 @@ class SecuritySetupFragment : Fragment() {
val hash = pbkdf2(input, salt) val hash = pbkdf2(input, salt)
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit() requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit()
.putString("security_method", method) .putString("security_method", method)
// Remove legacy plaintext fields if they exist from an old install
.remove("security_salt")
.remove("security_hash")
.apply() .apply()
CredentialStore(requireContext()).saveSecurityHash(saltB64, hash) CredentialStore(requireContext()).saveSecurityHash(saltB64, hash)
} }
@@ -256,6 +234,7 @@ class SecuritySetupFragment : Fragment() {
private fun finishSetup() { private fun finishSetup() {
val cb = activity as? Callback val cb = activity as? Callback
if (cb != null) { if (cb != null) {
goTo(Step.CONFIGURED)
cb.onSecuritySetupComplete() cb.onSecuritySetupComplete()
} else { } else {
parentFragmentManager.popBackStack() parentFragmentManager.popBackStack()
@@ -93,32 +93,10 @@ class CredentialStore(context: Context) {
// ── BML login credentials (multi-login, keyed by loginId = username) ──────── // ── BML login credentials (multi-login, keyed by loginId = username) ────────
fun getBmlLoginIds(): List<String> { fun getBmlLoginIds(): List<String> {
val json = prefs.getString("bml_login_ids", null) val json = prefs.getString("bml_login_ids", null) ?: return emptyList()
if (json != null) {
return try {
val arr = org.json.JSONArray(json)
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
}
// One-time migration from single-slot BML storage
val oldEncUsername = prefs.getString("bml_enc_username", null) ?: return emptyList()
return try { return try {
val key = getOrCreateKey() val arr = org.json.JSONArray(json)
val loginId = decrypt(oldEncUsername, key) (0 until arr.length()).map { arr.getString(it) }
val edit = prefs.edit()
prefs.getString("bml_enc_password", null)?.let { edit.putString("bml_${loginId}_enc_password", it) }
prefs.getString("bml_enc_otp_seed", null)?.let { edit.putString("bml_${loginId}_enc_otp_seed", it) }
prefs.getString("bml_enc_token", null)?.let { edit.putString("bml_${loginId}_enc_token", it) }
prefs.getString("bml_enc_device_id", null)?.let { edit.putString("bml_${loginId}_enc_device_id", it) }
prefs.getString("bml_enc_profile", null)?.let { edit.putString("bml_${loginId}_enc_profile", it) }
edit.putString("bml_${loginId}_enc_username", oldEncUsername)
edit.remove("bml_enc_username").remove("bml_enc_password").remove("bml_enc_otp_seed")
.remove("bml_enc_token").remove("bml_enc_device_id")
.remove("bml_enc_profile").remove("bml_enc_full_name")
val ids = org.json.JSONArray(listOf(loginId)).toString()
edit.putString("bml_login_ids", ids)
edit.apply()
listOf(loginId)
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
} }
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zm-2,15l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z" />
</vector>
+8 -3
View File
@@ -4,17 +4,20 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawerLayout" android:id="@+id/drawerLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
android:fitsSystemWindows="true">
<!-- Main content --> <!-- Main content -->
<androidx.coordinatorlayout.widget.CoordinatorLayout <androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface"> android:background="?attr/colorSurface"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout <com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"
@@ -49,6 +52,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="gone" android:visibility="gone"
android:fitsSystemWindows="true"
app:menu="@menu/bottom_nav_menu" /> app:menu="@menu/bottom_nav_menu" />
</LinearLayout> </LinearLayout>
@@ -61,6 +65,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_gravity="start" android:layout_gravity="start"
android:fitsSystemWindows="true"
app:menu="@menu/drawer_menu" /> app:menu="@menu/drawer_menu" />
</androidx.drawerlayout.widget.DrawerLayout> </androidx.drawerlayout.widget.DrawerLayout>
+2 -1
View File
@@ -4,7 +4,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface"> android:background="?attr/colorSurface"
android:fitsSystemWindows="true">
<androidx.fragment.app.FragmentContainerView <androidx.fragment.app.FragmentContainerView
android:id="@+id/navHostFragment" android:id="@+id/navHostFragment"
+37 -17
View File
@@ -27,31 +27,49 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"> app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.chip.ChipGroup <LinearLayout
android:id="@+id/languageChipGroup" android:id="@+id/languageSection"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_horizontal" android:orientation="vertical"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:visibility="gone" android:visibility="gone">
app:singleSelection="true"
app:selectionRequired="false">
<com.google.android.material.chip.Chip <TextView
android:id="@+id/chipEnglish"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="English" android:layout_gravity="center_horizontal"
style="@style/Widget.Material3.Chip.Filter" /> android:text="@string/select_language"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:layout_marginBottom="8dp" />
<com.google.android.material.chip.Chip <com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/chipDhivehi" android:id="@+id/languageToggle"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="ދިވެހި" app:singleSelection="true"
style="@style/Widget.Material3.Chip.Filter" /> app:selectionRequired="true">
</com.google.android.material.chip.ChipGroup> <com.google.android.material.button.MaterialButton
android:id="@+id/btnLangEnglish"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="English" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLangDhivehi"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="ދިވެހި" />
</com.google.android.material.button.MaterialButtonToggleGroup>
</LinearLayout>
<com.google.android.material.tabs.TabLayout <com.google.android.material.tabs.TabLayout
android:id="@+id/dotsIndicator" android:id="@+id/dotsIndicator"
@@ -61,6 +79,8 @@
android:background="@null" android:background="@null"
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:layout_marginBottom="24dp" android:layout_marginBottom="24dp"
android:clickable="false"
android:focusable="false"
app:tabBackground="@drawable/tab_indicator_selector" app:tabBackground="@drawable/tab_indicator_selector"
app:tabGravity="center" app:tabGravity="center"
app:tabIndicatorHeight="0dp" app:tabIndicatorHeight="0dp"
@@ -10,6 +10,7 @@
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<LinearLayout <LinearLayout
android:id="@+id/btnContainer"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal" android:layout_gravity="bottom|center_horizontal"
@@ -80,7 +80,6 @@
android:hint="@string/otp_seed" android:hint="@string/otp_seed"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
app:endIconMode="password_toggle" app:endIconMode="password_toggle"
app:helperText="@string/otp_seed_hint"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"> style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/etOtpSeed" android:id="@+id/etOtpSeed"
@@ -116,7 +115,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
android:visibility="gone" android:visibility="invisible"
app:cardBackgroundColor="?attr/colorSecondaryContainer" app:cardBackgroundColor="?attr/colorSecondaryContainer"
app:cardCornerRadius="12dp" app:cardCornerRadius="12dp"
app:cardElevation="0dp"> app:cardElevation="0dp">
@@ -0,0 +1,218 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
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">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp"
android:paddingTop="40dp">
<!-- Appearance -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_appearance"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_navigation"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:layout_marginBottom="8dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/navModeToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNavDrawer"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_nav_drawer" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNavBottom"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_nav_bottom" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/theme"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:layout_marginBottom="8dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/themeToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnThemeSystem"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/theme_system" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnThemeLight"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/theme_light" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnThemeDark"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/theme_dark" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<!-- Privacy & Security -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_privacy_security"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="24dp"
android:layout_marginBottom="16dp" />
<!-- Biometrics -->
<LinearLayout
android:id="@+id/rowBiometrics"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_biometrics"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/tvBiometricsHint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_biometrics_unavailable"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp"
android:visibility="gone" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_biometrics_unlock"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchBiometrics"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_biometrics_transfer"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchBiometricsTransfer"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
<!-- Block screenshots -->
<LinearLayout
android:id="@+id/rowBlockScreenshots"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
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/settings_block_screenshots"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_block_screenshots_desc"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchBlockScreenshots"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp" />
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -1,94 +1,96 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ScrollView <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fillViewport="true"> android:orientation="vertical"
android:gravity="center_horizontal"
android:paddingHorizontal="32dp"
android:paddingTop="64dp"
android:paddingBottom="16dp">
<LinearLayout <ImageView
android:id="@+id/icon"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_marginBottom="40dp"
android:contentDescription="@string/app_name" />
<TextView
android:id="@+id/title"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:gravity="center_horizontal" android:textColor="?attr/colorOnSurface"
android:paddingHorizontal="32dp" android:gravity="center"
android:paddingTop="64dp" android:layout_marginBottom="16dp" />
android:paddingBottom="16dp">
<ImageView <ScrollView
android:id="@+id/icon" android:id="@+id/scrollView"
android:layout_width="120dp" android:layout_width="match_parent"
android:layout_height="120dp" android:layout_height="0dp"
android:layout_marginBottom="40dp" android:layout_weight="1"
android:contentDescription="@string/app_name" /> android:fillViewport="true">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:textColor="?attr/colorOnSurface"
android:gravity="center"
android:layout_marginBottom="16dp" />
<TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center"
android:lineSpacingMultiplier="1.4" />
<!-- Bank logo cards shown only on first slide -->
<LinearLayout <LinearLayout
android:id="@+id/placeholderCards"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="vertical">
android:layout_marginTop="40dp"
android:weightSum="2"
android:visibility="gone">
<com.google.android.material.card.MaterialCardView <TextView
android:layout_width="0dp" android:id="@+id/description"
android:layout_height="80dp" android:layout_width="match_parent"
android:layout_weight="1" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:textAppearance="?attr/textAppearanceBodyLarge"
app:cardCornerRadius="12dp" android:textColor="?attr/colorOnSurfaceVariant"
app:strokeWidth="1dp" android:gravity="center"
app:strokeColor="?attr/colorOutline"> android:lineSpacingMultiplier="1.4" />
<!-- Supported services shown only on first slide -->
<LinearLayout
android:id="@+id/placeholderCards"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="40dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:text="@string/onboarding_supported_services"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="16dp" />
<ImageView <ImageView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="40dp"
android:src="@drawable/mib_faisanet_logo" android:src="@drawable/mib_faisanet_logo"
android:scaleType="centerInside" android:scaleType="centerInside"
android:padding="12dp"
android:contentDescription="@string/mib_name" /> android:contentDescription="@string/mib_name" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="0dp"
android:layout_height="80dp"
android:layout_weight="1"
android:layout_marginStart="8dp"
app:cardCornerRadius="12dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
<ImageView <ImageView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="40dp"
android:layout_marginTop="24dp"
android:src="@drawable/bml_logo_vector" android:src="@drawable/bml_logo_vector"
android:scaleType="centerInside" android:scaleType="centerInside"
android:padding="8dp"
android:contentDescription="@string/bml_name" /> android:contentDescription="@string/bml_name" />
</com.google.android.material.card.MaterialCardView> <ImageView
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="24dp"
android:src="@drawable/fahipay_logo_long"
android:scaleType="centerInside"
android:contentDescription="@string/fahipay_name" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</ScrollView>
</LinearLayout> </LinearLayout>
</ScrollView>
@@ -19,11 +19,12 @@
android:paddingBottom="24dp" android:paddingBottom="24dp"
android:gravity="center_horizontal"> android:gravity="center_horizontal">
<TextView <ImageView
android:layout_width="wrap_content" android:layout_width="80dp"
android:layout_height="wrap_content" android:layout_height="80dp"
android:text="🔒" android:src="@drawable/ic_lock"
android:textSize="56sp" android:tint="?attr/colorPrimary"
android:contentDescription="@null"
android:layout_marginBottom="16dp" /> android:layout_marginBottom="16dp" />
<TextView <TextView
@@ -255,73 +256,49 @@
</LinearLayout> </LinearLayout>
<!-- Step: Biometric prompt -->
<!-- Step: Already configured -->
<LinearLayout <LinearLayout
android:id="@+id/viewBiometric" android:id="@+id/viewConfigured"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:gravity="center"
android:paddingHorizontal="24dp" android:paddingHorizontal="24dp"
android:paddingTop="40dp"
android:paddingBottom="24dp"
android:gravity="center_horizontal"
android:visibility="gone"> android:visibility="gone">
<ImageView
android:layout_width="80dp"
android:layout_height="80dp"
android:src="@drawable/ic_check_circle"
android:tint="?attr/colorPrimary"
android:contentDescription="@null"
android:layout_marginBottom="24dp" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="🔐" android:text="@string/security_already_configured"
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:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorOnSurface" android:textColor="?attr/colorOnSurface"
android:gravity="center" android:gravity="center"
android:layout_marginBottom="12dp" /> android:layout_marginBottom="8dp" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/biometric_desc" android:text="@string/security_already_configured_desc"
android:textAppearance="?attr/textAppearanceBodyMedium" android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center" android:gravity="center"
android:layout_marginBottom="16dp" /> android:layout_marginBottom="32dp" />
<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 <com.google.android.material.button.MaterialButton
android:id="@+id/btnEnableBiometrics" android:id="@+id/btnChangeLock"
android:layout_width="match_parent" style="@style/Widget.Material3.Button.OutlinedButton"
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_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/skip_biometrics" /> android:text="@string/settings_change_lock" />
</LinearLayout> </LinearLayout>
@@ -31,8 +31,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:layout_marginTop="16dp" android:layout_marginTop="16dp">
android:visibility="gone">
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -40,7 +39,17 @@
android:text="@string/settings_biometrics" android:text="@string/settings_biometrics"
android:textAppearance="?attr/textAppearanceTitleSmall" android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurfaceVariant" android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" /> android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/tvBiometricsHint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_biometrics_unavailable"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp"
android:visibility="gone" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
+3 -1
View File
@@ -3,12 +3,14 @@
<string name="app_name">BasedBank</string> <string name="app_name">BasedBank</string>
<!-- Onboarding --> <!-- Onboarding -->
<string name="onboarding_supported_services">ހިދުމަތްތައް</string>
<string name="select_language">ބަސް ހިޔާލު ކުރޭ</string>
<string name="onboarding_title_1">ތިޔަ ބޭންކްތައް، އެއް އެޕެއްގައި</string> <string name="onboarding_title_1">ތިޔަ ބޭންކްތައް، އެއް އެޕެއްގައި</string>
<string name="onboarding_desc_1">BasedBank ގެ ސަބަބުން ތިޔަ ދިވެހި ބޭންކު އެކައުންޓްތައް، ހަމައެއް ތަނަކުން ބެލޭ. ބެލެންސް ބެލޭ، ތަފާތު ތަންތަން ބެލޭ — ތަފާތު އެޕްތަކަށް ބަދަލު ނުވެ.</string> <string name="onboarding_desc_1">BasedBank ގެ ސަބަބުން ތިޔަ ދިވެހި ބޭންކު އެކައުންޓްތައް، ހަމައެއް ތަނަކުން ބެލޭ. ބެލެންސް ބެލޭ، ތަފާތު ތަންތަން ބެލޭ — ތަފާތު އެޕްތަކަށް ބަދަލު ނުވެ.</string>
<string name="onboarding_title_2">އިތުރު ބޭންކްތައް ހިމެނެނީ</string> <string name="onboarding_title_2">އިތުރު ބޭންކްތައް ހިމެނެނީ</string>
<string name="onboarding_desc_2">އިތުރު ބޭންކްތަކަށް ސަޕޯޓް ލިބޭ ގޮތަށް ތައްޔާރުވަމުން ދަނީ. ދިވެހިރާއްޖޭގެ ބޭންކްތަކަށް ސަޕޯޓް ފަހި ވަމުން ދިޔަ ވަރަކަށް ހިމަނެމުން ދޭ.</string> <string name="onboarding_desc_2">އިތުރު ބޭންކްތަކަށް ސަޕޯޓް ލިބޭ ގޮތަށް ތައްޔާރުވަމުން ދަނީ. ދިވެހިރާއްޖޭގެ ބޭންކްތަކަށް ސަޕޯޓް ފަހި ވަމުން ދިޔަ ވަރަކަށް ހިމަނެމުން ދޭ.</string>
<string name="onboarding_title_3">ފެށޭ ގޮތަށް ތައްޔާރު</string> <string name="onboarding_title_3">ފެށޭ ގޮތަށް ތައްޔާރު</string>
<string name="onboarding_desc_3">ތިޔަ ބޭންކު ކްރެޑެންޝަލް ޖެހި، ތިޔަ އެކައުންޓްތައް ބަލާ. ތިޔަ ޑޭޓާ ހިފެހެއްޓޭ ތަނަކީ ހަމައެކަނި ތިޔަ ފޯނު.</string> <string name="onboarding_desc_3">ތިޔަ ބޭންކު ކްރެޑެންޝަލް ޖެހި، ތިޔަ އެކައުންޓްތައް ބަލާ. ތިޔަ ޑޭޓާ ހިފެހެއްޓޭ ތަނަކީ ހަމައެކަނި ތިޔަ ފޯނު.\n\nDhiraagu އާއި Ooredoo ގެ API ބޭނުންކޮށްގެން ފޯން ނަންބަރުގެ ތަފްސީލު ބެލޭ.\n\nމި އެޕް ތިޔައާ ބެހޭ ތަފްސީލެއް ނެހެދޭ، ޑިވެލޮޕަރަށް ވެސް ނުފޮނުވާ. ހުރިހާ ޑޭޓާ ހިފެހެއްޓޭ ތަނަކީ ހަމައެކަނި ތިޔަ ފޯނު.</string>
<string name="coming_soon">ފަހުން ލިބޭ</string> <string name="coming_soon">ފަހުން ލިބޭ</string>
<string name="next">ދެން</string> <string name="next">ދެން</string>
<string name="get_started">ފަށާ</string> <string name="get_started">ފަށާ</string>
+8 -2
View File
@@ -2,12 +2,14 @@
<string name="app_name">BasedBank</string> <string name="app_name">BasedBank</string>
<!-- Onboarding --> <!-- Onboarding -->
<string name="onboarding_supported_services">Supported services</string>
<string name="select_language">Select Language</string>
<string name="onboarding_title_1">Your Banks, One App</string> <string name="onboarding_title_1">Your Banks, One App</string>
<string name="onboarding_desc_1">BasedBank brings all your Maldivian bank accounts together in one place. Check balances, view accounts, and more — without switching between apps.</string> <string name="onboarding_desc_1">BasedBank brings all your Maldivian bank accounts together in one place. Check balances, view accounts, and more — without switching between apps.</string>
<string name="onboarding_title_2">More Banks Coming</string> <string name="onboarding_title_2">More Banks Coming</string>
<string name="onboarding_desc_2">Support for additional banks is on the way. Stay tuned as we expand coverage across the Maldives.</string> <string name="onboarding_desc_2">Support for additional banks is on the way. Stay tuned as we expand coverage across the Maldives.</string>
<string name="onboarding_title_3">Get Started</string> <string name="onboarding_title_3">Before You Begin</string>
<string name="onboarding_desc_3">Add your bank credentials and start viewing your accounts. Your data stays on your device.</string> <string name="onboarding_desc_3">BasedBank is an independent, third-party app. It is not affiliated with, endorsed by, or officially supported by any bank or financial institution.\n\nThis app works by logging into your internet banking services directly using your credentials and communicating with bank APIs. It is built using techniques derived from reverse-engineered official bank apps. Behaviour may change or break without notice if banks update their systems.\n\nDhiraagu and Ooredoo APIs are used to look up details about phone numbers.\n\nThis app does not collect any analytics, telemetry, or personal data, and does not transmit any information about you or your usage to the developer. Everything stays entirely on your device.\n\nBy tapping Get Started, you acknowledge and accept that:\n\n• Errors, failures, or service interruptions may occur at any time\n• Your bank may detect third-party access and apply restrictions or take other actions against your account\n• The developer of this app is not liable for any loss, damage, or consequences arising from your use of this app\n• You use this app entirely at your own risk</string>
<string name="coming_soon">Coming Soon</string> <string name="coming_soon">Coming Soon</string>
<string name="next">Next</string> <string name="next">Next</string>
<string name="get_started">Get Started</string> <string name="get_started">Get Started</string>
@@ -49,6 +51,9 @@
<!-- Security setup --> <!-- Security setup -->
<string name="security_setup">Secure Your App</string> <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="security_setup_desc">Choose how you want to lock BasedBank when you\'re away.</string>
<string name="security_already_configured">App Lock Configured</string>
<string name="security_already_configured_desc">Your app lock is set up.</string>
<string name="method_pin">PIN Code</string> <string name="method_pin">PIN Code</string>
<string name="method_pin_desc">48 digit numeric PIN</string> <string name="method_pin_desc">48 digit numeric PIN</string>
<string name="method_pattern">Draw Pattern</string> <string name="method_pattern">Draw Pattern</string>
@@ -104,6 +109,7 @@
<string name="settings_security">Security</string> <string name="settings_security">Security</string>
<string name="settings_change_lock">Change PIN / Pattern</string> <string name="settings_change_lock">Change PIN / Pattern</string>
<string name="settings_biometrics">Use biometrics</string> <string name="settings_biometrics">Use biometrics</string>
<string name="settings_biometrics_unavailable">No biometrics enrolled on this device</string>
<string name="settings_biometrics_unlock">To unlock app</string> <string name="settings_biometrics_unlock">To unlock app</string>
<string name="settings_biometrics_transfer">Confirm transfer</string> <string name="settings_biometrics_transfer">Confirm transfer</string>
<string name="biometric_transfer_title">Confirm Transfer</string> <string name="biometric_transfer_title">Confirm Transfer</string>