Optmize business profile logins.. save privacy mode settings on launch

This commit is contained in:
2026-05-21 00:43:05 +05:00
parent 6d48c27391
commit fe507073b1
9 changed files with 304 additions and 161 deletions
@@ -194,8 +194,7 @@ class HomeActivity : AppCompatActivity() {
autoRefresh(store)
}
// hideAmounts is always false on launch; eye feature just needs to be enabled
viewModel.hideAmounts.value = false
viewModel.hideAmounts.value = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_amounts", false)
// Show dashboard on first create
if (savedInstanceState == null) {
@@ -434,6 +433,7 @@ fun applyNavLabelVisibility() {
if (item.itemId == R.id.action_hide_amounts) {
val newHidden = !(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.value = newHidden
getSharedPreferences("prefs", MODE_PRIVATE).edit().putBoolean("hide_amounts", newHidden).apply()
invalidateOptionsMenu()
return true
}
@@ -11,6 +11,7 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.materialswitch.MaterialSwitch
import sh.sar.basedbank.BasedBankApp
@@ -27,11 +28,21 @@ import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.FinancingCache
import sh.sar.basedbank.util.ForeignLimitsCache
import sh.sar.basedbank.util.RecentsCache
import androidx.lifecycle.lifecycleScope
import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlOtpChannel
class SettingsLoginsFragment : Fragment() {
private var _binding: FragmentSettingsLoginsBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
@@ -89,13 +100,15 @@ class SettingsLoginsFragment : Fragment() {
val profile = store.loadFahipayUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••"
showLoginDetails(
title = getString(R.string.fahipay_name),
details = buildString {
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}")
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.nid}")
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${if (hide) masked else profile!!.email}")
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${if (hide) masked else profile!!.mobile}")
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${if (hide) masked else profile!!.nid}")
}.trim(),
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } }
)
@@ -258,8 +271,16 @@ class SettingsLoginsFragment : Fragment() {
) {
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val originalHidden = store.getHiddenBmlProfileIds(loginId)
val hidden = originalHidden.toMutableSet()
val hidden = store.getHiddenBmlProfileIds(loginId).toMutableSet()
// Business profiles with no saved session were skipped during login — ensure they start hidden
val needsActivation = bmlProfiles
.filter { it.profileType == "business" && store.loadBmlProfileSession(it.profileId) == null }
.map { it.profileId }
.toMutableSet()
for (id in needsActivation) {
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
}
val originalHidden = hidden.toSet()
val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply {
@@ -269,12 +290,14 @@ class SettingsLoginsFragment : Fragment() {
}
scroll.addView(container)
val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••"
listOfNotNull(
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: $it" },
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $it" },
profile?.customerId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_customer_id)}: $it" },
profile?.idCard?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: $it" }
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" },
profile?.customerId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_customer_id)}: ${if (hide) masked else it}" },
profile?.idCard?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: ${if (hide) masked else it}" }
).forEach { line ->
container.addView(TextView(ctx).apply {
text = line
@@ -357,8 +380,19 @@ class SettingsLoginsFragment : Fragment() {
toggleRows.forEach { (p, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
updateToggleStates(saveBtn)
if (checked && p.profileId in needsActivation) {
toggle.isChecked = false // revert — enabling requires OTP
viewLifecycleOwner.lifecycleScope.launch {
val success = activateBmlBusinessProfile(store, loginId, p)
if (success) {
needsActivation.remove(p.profileId)
toggle.isChecked = true // listener re-fires, removes from hidden
}
}
} else {
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
updateToggleStates(saveBtn)
}
}
}
@@ -366,7 +400,209 @@ class SettingsLoginsFragment : Fragment() {
store.setHiddenBmlProfileIds(loginId, hidden)
clearAllCaches(ctx)
dialog.dismiss()
(activity as? HomeActivity)?.applyProfileVisibility()
(activity as? HomeActivity)?.relogin()
}
}
private suspend fun activateBmlBusinessProfile(
store: CredentialStore,
loginId: String,
profile: BmlProfile
): Boolean {
val creds = store.loadBmlCredentials(loginId) ?: run {
showSimpleError("Credentials not found — please log out and log in again")
return false
}
val progressDialog = MaterialAlertDialogBuilder(requireContext())
.setMessage("Connecting to BML\u2026")
.setCancelable(false)
.show()
val flow = BmlLoginFlow()
val loginTag = "bml_$loginId"
val activationResult = try {
withContext(Dispatchers.IO) {
flow.login(creds.username, creds.password, creds.otpSeed)
flow.activateProfile(profile, loginTag)
}
} catch (e: Exception) {
progressDialog.dismiss()
showSimpleError(e.message ?: "Authentication failed")
return false
}
progressDialog.dismiss()
return when (activationResult) {
is BmlActivationResult.Success -> {
store.saveBmlProfileSession(profile.profileId, activationResult.session.accessToken, activationResult.session.deviceId)
true
}
is BmlActivationResult.NeedsBusinessOtp ->
continueBmlBusinessOtpFlow(store, loginId, profile, flow, loginTag, activationResult.channels)
}
}
private suspend fun continueBmlBusinessOtpFlow(
store: CredentialStore,
loginId: String,
profile: BmlProfile,
flow: BmlLoginFlow,
loginTag: String,
channels: List<BmlOtpChannel>
): Boolean {
val selectedChannel = showBmlChannelSelectionDialog(profile.name, channels) ?: return false
val channelObj = channels.first { it.channel == selectedChannel }
val sendProgress = MaterialAlertDialogBuilder(requireContext())
.setMessage("Sending OTP\u2026")
.setCancelable(false)
.show()
try {
withContext(Dispatchers.IO) { flow.requestBusinessOtp(selectedChannel) }
} catch (e: Exception) {
sendProgress.dismiss()
showSimpleError(e.message ?: "Failed to send OTP")
return false
}
sendProgress.dismiss()
var otpError: String? = null
while (true) {
val code = showBmlOtpInputDialog(profile.name, channelObj, otpError) ?: return false
val verifyProgress = MaterialAlertDialogBuilder(requireContext())
.setMessage("Verifying\u2026")
.setCancelable(false)
.show()
try {
val (session, _) = withContext(Dispatchers.IO) {
flow.submitBusinessOtp(selectedChannel, code, profile, loginTag)
}
verifyProgress.dismiss()
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
return true
} catch (e: Exception) {
verifyProgress.dismiss()
if (e.message?.contains("Invalid OTP") == true) {
otpError = e.message
} else {
showSimpleError(e.message ?: "Verification failed")
return false
}
}
}
}
private fun showSimpleError(message: String) {
MaterialAlertDialogBuilder(requireContext())
.setMessage(message)
.setPositiveButton(R.string.close, null)
.show()
}
private suspend fun showBmlChannelSelectionDialog(profileName: String, channels: List<BmlOtpChannel>): String? =
suspendCancellableCoroutine { cont ->
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val list = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val vp = (8 * dp).toInt()
setPadding(0, vp, 0, vp)
}
for (channel in channels) {
val iconRes = when (channel.channel) {
"Email" -> R.drawable.ic_channel_email
"Mobile" -> R.drawable.ic_channel_sms
"WhatsApp" -> R.drawable.ic_channel_whatsapp
else -> R.drawable.ic_channel_sms
}
val iconSize = (24 * dp).toInt()
val iconView = ImageView(ctx).apply {
setImageResource(iconRes)
}
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
marginStart = (12 * dp).toInt()
}
}
textCol.addView(TextView(ctx).apply {
text = channel.description
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
})
textCol.addView(TextView(ctx).apply {
text = channel.masked
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.6f
})
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
background = ta.getDrawable(0); ta.recycle()
isClickable = true; isFocusable = true
val hp = (24 * dp).toInt(); val vp = (12 * dp).toInt()
setPadding(hp, vp, hp, vp)
}
row.addView(iconView, LinearLayout.LayoutParams(iconSize, iconSize))
row.addView(textCol)
list.addView(row)
}
val d = MaterialAlertDialogBuilder(ctx)
.setTitle("Send verification code")
.setView(list)
.setNegativeButton(R.string.cancel) { _, _ -> if (cont.isActive) cont.resume(null) }
.setCancelable(false)
.show()
d.setOnCancelListener { if (cont.isActive) cont.resume(null) }
// Wire up row clicks after dialog is created so we can dismiss it first
val rows = list.run { (0 until childCount).map { getChildAt(it) } }
rows.forEachIndexed { i, row ->
row.setOnClickListener {
d.dismiss()
if (cont.isActive) cont.resume(channels[i].channel)
}
}
}
private suspend fun showBmlOtpInputDialog(
profileName: String,
channel: BmlOtpChannel,
errorMsg: String? = null
): String? = suspendCancellableCoroutine { cont ->
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val input = android.widget.EditText(ctx).apply {
hint = "Enter OTP"
inputType = android.text.InputType.TYPE_CLASS_NUMBER
filters = arrayOf(android.text.InputFilter.LengthFilter(6))
setPadding((24 * dp).toInt(), (8 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
}
val msg = buildString {
append(getString(R.string.bml_business_otp_sent, channel.description))
append(" (${channel.masked})")
if (errorMsg != null) append("\n\n$errorMsg")
}
val d = MaterialAlertDialogBuilder(ctx)
.setTitle("Enter verification code")
.setMessage(msg)
.setView(input)
.setPositiveButton(R.string.verify, null)
.setNegativeButton(R.string.cancel) { _, _ -> if (cont.isActive) cont.resume(null) }
.setCancelable(false)
.show()
d.setOnCancelListener { if (cont.isActive) cont.resume(null) }
d.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val code = input.text.toString().trim()
if (code.length != 6) {
input.error = "Enter 6 digits"
} else {
d.dismiss()
if (cont.isActive) cont.resume(code)
}
}
}
@@ -21,8 +21,6 @@ import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlOtpChannel
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.fahipay.FahipaySession
@@ -34,8 +32,6 @@ import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
import sh.sar.basedbank.ui.home.HomeActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
class CredentialsFragment : Fragment() {
@@ -60,10 +56,6 @@ class CredentialsFragment : Fragment() {
private var bmlFlow: BmlLoginFlow? = null
private var bmlLoginId: String = ""
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
private var bmlPendingBusinessProfiles = ArrayDeque<Pair<BmlProfile, List<BmlOtpChannel>>>()
private var bmlCurrentBusinessProfile: BmlProfile? = null
private var bmlSelectedChannel: String? = null
private var bmlAwaitingBusinessOtp = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
@@ -170,11 +162,7 @@ class CredentialsFragment : Fragment() {
private fun attemptLogin() {
when (bankType) {
"BML" -> {
if (bmlAwaitingBusinessOtp) submitBmlBusinessOtp()
else attemptBmlLogin()
return
}
"BML" -> { attemptBmlLogin(); return }
"FAHIPAY" -> { attemptFahipayLogin(); return }
}
@@ -257,10 +245,6 @@ class CredentialsFragment : Fragment() {
bmlLoginId = username
bmlAccumulatedAccounts.clear()
bmlPendingBusinessProfiles.clear()
bmlCurrentBusinessProfile = null
bmlSelectedChannel = null
bmlAwaitingBusinessOtp = false
val flow = BmlLoginFlow().also { bmlFlow = it }
val loginTag = "bml_$username"
@@ -272,24 +256,22 @@ class CredentialsFragment : Fragment() {
}
if (profiles.isEmpty()) throw Exception("No profiles found for this account")
// Activate each profile; personal profiles are immediate, business ones need OTP
var hasBusinessProfiles = false
for (profile in profiles) {
if (profile.profileType == "business") {
hasBusinessProfiles = true
continue // skip — user can enable in Settings → Logins
}
val result = withContext(Dispatchers.IO) { flow.activateProfile(profile, loginTag) }
when (result) {
is BmlActivationResult.Success -> {
bmlAccumulatedAccounts += result.accounts
val store = CredentialStore(requireContext())
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
val app = requireActivity().application as BasedBankApp
app.bmlSessions[profile.profileId] = result.session
}
is BmlActivationResult.NeedsBusinessOtp -> {
bmlPendingBusinessProfiles.addLast(Pair(profile, result.channels))
}
if (result is BmlActivationResult.Success) {
bmlAccumulatedAccounts += result.accounts
val store = CredentialStore(requireContext())
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
val app = requireActivity().application as BasedBankApp
app.bmlSessions[profile.profileId] = result.session
}
}
// Save credentials and profile list now (before business OTP prompts)
val store = CredentialStore(requireContext())
store.saveBmlCredentials(bmlLoginId, username, password, otpSeed)
store.saveBmlProfiles(bmlLoginId, profiles)
@@ -300,11 +282,7 @@ class CredentialsFragment : Fragment() {
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
if (bmlPendingBusinessProfiles.isNotEmpty()) {
processNextBmlBusinessProfile()
} else {
finishBmlLogin()
}
finishBmlLogin(hasBusinessProfiles)
} catch (e: Exception) {
binding.tvError.text = e.message ?: "Login failed"
binding.tvError.visibility = View.VISIBLE
@@ -314,115 +292,7 @@ class CredentialsFragment : Fragment() {
}
}
private suspend fun processNextBmlBusinessProfile() {
val (profile, channels) = bmlPendingBusinessProfiles.removeFirstOrNull()
?: run { finishBmlLogin(); return }
bmlCurrentBusinessProfile = profile
// Show channel selection dialog
val selectedChannel = showBmlChannelDialog(profile.name, channels) ?: run {
// User skipped this profile — move on
processNextBmlBusinessProfile()
return
}
bmlSelectedChannel = selectedChannel
// Request OTP
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
try {
withContext(Dispatchers.IO) {
bmlFlow!!.requestBusinessOtp(selectedChannel)
}
} catch (e: Exception) {
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
binding.tvError.text = e.message ?: "Failed to send OTP"
binding.tvError.visibility = View.VISIBLE
processNextBmlBusinessProfile()
return
}
// Show OTP input — disable credential fields (same pattern as Fahipay TOTP step)
bmlAwaitingBusinessOtp = true
binding.etUsername.isEnabled = false
binding.etPassword.isEnabled = false
binding.etOtpSeed.isEnabled = false
binding.progressBar.visibility = View.GONE
binding.tilTotpCode.hint = getString(R.string.bml_business_otp_hint, profile.name)
binding.tilTotpCode.helperText = getString(R.string.bml_business_otp_sent, selectedChannel)
binding.tilTotpCode.visibility = View.VISIBLE
binding.etTotpCode.text?.clear()
binding.btnLogin.text = getString(R.string.verify)
binding.btnLogin.isEnabled = true
binding.tvError.visibility = View.GONE
}
private fun submitBmlBusinessOtp() {
val code = binding.etTotpCode.text.toString().trim()
if (code.length != 6) {
binding.tvError.text = getString(R.string.fahipay_totp_hint)
binding.tvError.visibility = View.VISIBLE
return
}
binding.tvError.visibility = View.GONE
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
val profile = bmlCurrentBusinessProfile ?: return
val channel = bmlSelectedChannel ?: return
val loginTag = "bml_$bmlLoginId"
viewLifecycleOwner.lifecycleScope.launch {
try {
val (session, accounts) = withContext(Dispatchers.IO) {
bmlFlow!!.submitBusinessOtp(channel, code, profile, loginTag)
}
bmlAccumulatedAccounts += accounts
val store = CredentialStore(requireContext())
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
val app = requireActivity().application as BasedBankApp
app.bmlSessions[profile.profileId] = session
bmlAwaitingBusinessOtp = false
binding.tilTotpCode.visibility = View.GONE
binding.btnLogin.text = getString(R.string.login)
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
if (bmlPendingBusinessProfiles.isNotEmpty()) {
processNextBmlBusinessProfile()
} else {
finishBmlLogin()
}
} catch (e: Exception) {
binding.tvError.text = e.message ?: "OTP verification failed"
binding.tvError.visibility = View.VISIBLE
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
}
}
}
private suspend fun showBmlChannelDialog(profileName: String, channels: List<BmlOtpChannel>): String? =
suspendCancellableCoroutine { cont ->
val options = channels.map { "${it.description} (${it.masked})" }.toTypedArray()
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.bml_business_otp_title, profileName))
.setItems(options) { _, which ->
if (cont.isActive) cont.resume(channels[which].channel)
}
.setNegativeButton(R.string.bml_business_otp_skip) { _: android.content.DialogInterface, _: Int ->
if (cont.isActive) cont.resume(null as String?)
}
.show()
dialog.setOnCancelListener { if (cont.isActive) cont.resume(null as String?) }
}
private suspend fun finishBmlLogin() {
private suspend fun finishBmlLogin(hasBusinessProfiles: Boolean = false) {
val store = CredentialStore(requireContext())
val accounts = bmlAccumulatedAccounts.toList()
@@ -479,6 +349,9 @@ class CredentialsFragment : Fragment() {
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
app.accounts = app.accounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
if (hasBusinessProfiles) {
Toast.makeText(requireContext(), "Business profiles can be enabled in Settings → Logins", Toast.LENGTH_LONG).show()
}
val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)