BML account lookup and add new contacts

This commit is contained in:
2026-05-14 05:24:36 +05:00
parent 94d74db4dc
commit 5805b4cb51
14 changed files with 994 additions and 70 deletions
@@ -0,0 +1,459 @@
package sh.sar.basedbank.ui.home
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.net.Uri
import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import sh.sar.basedbank.util.ContactsCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountValidation
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibTransferClient
import sh.sar.basedbank.databinding.SheetAddContactBinding
import java.io.ByteArrayOutputStream
class AddContactSheetFragment : BottomSheetDialogFragment() {
private var _binding: SheetAddContactBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private val app get() = requireActivity().application as BasedBankApp
private data class DestinationOption(
val label: String,
val isBml: Boolean,
val mibProfile: MibProfile? = null
)
private var destinations: List<DestinationOption> = emptyList()
private var selectedDest: DestinationOption? = null
// Holds the resolved lookup result (for BML dest) or null (MIB uses account number directly)
private var bmlLookup: BmlAccountValidation? = null
private var mibLookupAccount: String? = null // resolved account for MIB save
private var selectedImageBase64: String = ""
private var selectedCategoryId: String = "0"
private var categories: List<MibBeneficiaryCategory> = emptyList()
private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri ?: return@registerForActivityResult
encodeImage(uri)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = SheetAddContactBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
destinations = buildDestinations()
setupDestinationDropdown()
setupAccountSearch()
setupImagePicker()
setupSaveButton()
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
categories = cats.filter { it.id != "BML" }
if (selectedDest?.isBml == false) setupCategoryDropdown()
}
}
private fun buildDestinations(): List<DestinationOption> {
val list = mutableListOf<DestinationOption>()
for (profile in app.mibProfiles) {
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile))
}
if (app.bmlSession != null) {
list.add(DestinationOption("BML · Personal", isBml = true))
}
return list
}
private fun setupDestinationDropdown() {
val labels = destinations.map { it.label }
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, labels)
binding.actvDestination.setAdapter(adapter)
binding.actvDestination.setOnItemClickListener { _, _, position, _ ->
selectedDest = destinations[position]
clearLookupResult()
updateMibOnlyVisibility()
binding.btnSave.isEnabled = false
}
if (destinations.size == 1) {
selectedDest = destinations[0]
binding.actvDestination.setText(destinations[0].label, false)
updateMibOnlyVisibility()
}
}
private fun updateMibOnlyVisibility() {
val isMib = selectedDest?.isBml == false
binding.layoutImage.visibility = if (isMib) View.VISIBLE else View.GONE
binding.tilGroup.visibility = if (isMib) View.VISIBLE else View.GONE
if (isMib) setupCategoryDropdown()
}
private fun setupCategoryDropdown() {
val items = mutableListOf(getString(R.string.contact_no_group))
items.addAll(categories.map { it.categoryName })
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, items)
binding.actvGroup.setAdapter(adapter)
binding.actvGroup.setOnItemClickListener { _, _, position, _ ->
selectedCategoryId = if (position == 0) "0" else categories[position - 1].id
}
if (binding.actvGroup.text.isNullOrBlank()) {
binding.actvGroup.setText(getString(R.string.contact_no_group), false)
}
}
private fun setupAccountSearch() {
binding.tilAccount.setEndIconOnClickListener { performLookup() }
}
private fun performLookup() {
val dest = selectedDest ?: run {
Toast.makeText(requireContext(), R.string.contact_select_destination, Toast.LENGTH_SHORT).show()
return
}
val input = binding.etAccount.text?.toString()?.trim() ?: ""
if (input.isBlank()) {
Toast.makeText(requireContext(), R.string.transfer_enter_account_first, Toast.LENGTH_SHORT).show()
return
}
// Check own accounts and existing contacts before any network call
val isBmlDest = dest.isBml
val isOwnAccountPre = viewModel.accounts.value?.any { acc ->
acc.accountNumber == input && when {
isBmlDest -> acc.loginTag.startsWith("bml_")
else -> acc.profileId == (dest.mibProfile?.profileId ?: "")
}
} == true
if (isOwnAccountPre) {
Toast.makeText(requireContext(), R.string.contact_own_account, Toast.LENGTH_SHORT).show()
return
}
val existing = viewModel.contacts.value?.firstOrNull { contact ->
contact.benefAccount == input && when {
isBmlDest -> contact.benefCategoryId == "BML"
else -> contact.profileId == (dest.mibProfile?.profileId ?: "")
}
}
if (existing != null) {
Toast.makeText(requireContext(), getString(R.string.contact_already_exists, existing.benefNickName), Toast.LENGTH_SHORT).show()
return
}
binding.tilAccount.isEnabled = false
binding.tilDestination.isEnabled = false
binding.btnSave.isEnabled = false
viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
if (dest.isBml) lookupForBml(input) else lookupForMib(dest, input)
}
binding.tilAccount.isEnabled = true
binding.tilDestination.isEnabled = true
if (result != null) {
showLookupResult(result, input)
} else {
Toast.makeText(requireContext(), R.string.contact_lookup_failed, Toast.LENGTH_SHORT).show()
}
}
}
private fun lookupForBml(input: String): BmlAccountValidation? {
val bmlSess = app.bmlSession ?: return null
val bmlFlow = BmlLoginFlow()
// 1) Try BML validate
val validated = try { bmlFlow.validateAccount(bmlSess, input) } catch (_: Exception) { null }
if (validated != null) return validated
// 2) Try BML MIB verify
val mibVerified = try { bmlFlow.verifyMibAccount(bmlSess, input) } catch (_: Exception) { null }
if (mibVerified != null) return mibVerified
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
val mibSess = app.mibSession ?: return null
return try {
val info = MibTransferClient().lookup(mibSess, input)
BmlAccountValidation(
trnType = "DOT",
validationType = "MIB",
account = info.accountNumber,
originalInput = input,
name = info.accountName,
alias = null,
currency = "MVR",
agnt = "MADVMVMV"
)
} catch (_: Exception) { null }
}
private fun lookupForMib(dest: DestinationOption, input: String): BmlAccountValidation? {
val mibSess = app.mibSession ?: return null
val profile = dest.mibProfile ?: return null
val mibResult = try {
app.mibLoginFlow.switchProfile(mibSess, profile)
val info = MibTransferClient().lookup(mibSess, input)
BmlAccountValidation(
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
validationType = "MIB",
account = info.accountNumber,
originalInput = input,
name = info.accountName,
alias = null,
currency = "MVR",
agnt = info.bankId
)
} catch (_: Exception) { null }
if (mibResult != null) return mibResult
// MIB lookup failed (e.g. BML USD account) — fall back to BML validate
val bmlSess = app.bmlSession ?: return null
return try { BmlLoginFlow().validateAccount(bmlSess, input) } catch (_: Exception) { null }
}
private fun showLookupResult(validation: BmlAccountValidation, input: String) {
// Re-check resolved account (input may have been a Favara ID)
val isBmlDest = selectedDest?.isBml == true
val isOwnAccountPost = viewModel.accounts.value?.any { acc ->
acc.accountNumber == validation.account && when {
isBmlDest -> acc.loginTag.startsWith("bml_")
else -> acc.profileId == (selectedDest?.mibProfile?.profileId ?: "")
}
} == true
if (isOwnAccountPost) {
Toast.makeText(requireContext(), R.string.contact_own_account, Toast.LENGTH_SHORT).show()
return
}
val existing = viewModel.contacts.value?.firstOrNull { contact ->
contact.benefAccount == validation.account && when {
isBmlDest -> contact.benefCategoryId == "BML"
else -> contact.profileId == (selectedDest?.mibProfile?.profileId ?: "")
}
}
if (existing != null) {
Toast.makeText(requireContext(), getString(R.string.contact_already_exists, existing.benefNickName), Toast.LENGTH_SHORT).show()
return
}
bmlLookup = validation
mibLookupAccount = validation.account
val displayName = validation.name.ifBlank { input }
val bankName = when {
validation.account.matches(Regex("^9\\d{16}$")) -> "MIB"
validation.account.matches(Regex("^7\\d{12}$")) -> "BML"
else -> validation.account
}
val bankLabel = "$bankName · ${validation.account}"
binding.tvResultName.text = displayName
binding.tvResultBank.text = bankLabel
binding.ivResultAvatar.setImageBitmap(makeInitialsBitmap(displayName, "#607D8B"))
binding.cardResult.visibility = View.VISIBLE
binding.tilAccount.visibility = View.GONE
// Auto-fill alias with existing alias or name
if (binding.etAlias.text.isNullOrBlank()) {
binding.etAlias.setText(validation.name)
}
binding.etCurrency.setText(validation.currency)
binding.btnClearResult.setOnClickListener { clearLookupResult() }
binding.btnSave.isEnabled = true
}
private fun clearLookupResult() {
bmlLookup = null
mibLookupAccount = null
binding.cardResult.visibility = View.GONE
binding.tilAccount.visibility = View.VISIBLE
binding.btnSave.isEnabled = false
}
private fun setupImagePicker() {
binding.btnPickImage.setOnClickListener {
imagePicker.launch("image/*")
}
}
private fun encodeImage(uri: Uri) {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val stream = requireContext().contentResolver.openInputStream(uri) ?: return@launch
var bmp = BitmapFactory.decodeStream(stream)
stream.close()
// Resize to max 256x256
if (bmp.width > 256 || bmp.height > 256) {
val scale = 256f / maxOf(bmp.width, bmp.height)
bmp = Bitmap.createScaledBitmap(bmp, (bmp.width * scale).toInt(), (bmp.height * scale).toInt(), true)
}
val out = ByteArrayOutputStream()
bmp.compress(Bitmap.CompressFormat.JPEG, 85, out)
selectedImageBase64 = Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
withContext(Dispatchers.Main) {
if (_binding != null) binding.ivContactImage.setImageBitmap(bmp)
}
} catch (_: Exception) { }
}
}
private fun setupSaveButton() {
binding.btnSave.setOnClickListener { performSave() }
}
private fun performSave() {
val dest = selectedDest ?: return
val alias = binding.etAlias.text?.toString()?.trim() ?: ""
if (alias.isBlank()) {
binding.tilAlias.error = "Alias is required"
return
}
binding.tilAlias.error = null
binding.btnSave.isEnabled = false
viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) {
if (dest.isBml) saveToBml(alias) else saveToMib(alias)
}
if (success) {
Toast.makeText(requireContext(), R.string.contact_saved, Toast.LENGTH_SHORT).show()
reloadSavedProfileContacts(dest)
dismiss()
} else {
binding.btnSave.isEnabled = true
Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show()
}
}
}
private fun saveToBml(alias: String): Boolean {
val bmlSess = app.bmlSession ?: return false
val lookup = bmlLookup ?: return false
val bmlFlow = BmlLoginFlow()
val account = lookup.account
return when {
account.matches(Regex("^7\\d{12}$")) ->
// BML account → IAT
bmlFlow.saveContact(bmlSess, "IAT", account, alias)
account.matches(Regex("^9\\d{16}$")) ->
// MIB internal → DOT; swift is BML's internal UUID for MIB bank
bmlFlow.saveContact(bmlSess, "DOT", account, alias,
currency = lookup.currency, name = lookup.name, swift = MIB_SWIFT_ON_BML)
else -> false
}
}
private fun saveToMib(alias: String): Boolean {
val mibSess = app.mibSession ?: return false
val dest = selectedDest ?: return false
val profile = dest.mibProfile ?: return false
val account = mibLookupAccount ?: return false
val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR"
val transferCy = if (currency.equals("USD", ignoreCase = true)) "840" else "462"
val isMibInternal = account.matches(Regex("^9\\d{16}$"))
val benefType = if (isMibInternal) "I" else "L"
val bankNo = if (isMibInternal) 2 else 3
val name = bmlLookup?.name ?: ""
return try {
app.mibLoginFlow.switchProfile(mibSess, profile)
MibContactsClient().createContact(
session = mibSess,
benefType = benefType,
bankNo = bankNo,
benefAccount = account,
benefName = name,
nickName = alias,
transferCy = transferCy,
categoryId = selectedCategoryId,
imageBase64 = selectedImageBase64
)
} catch (_: Exception) { false }
}
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
val sizePx = (resources.displayMetrics.density * 40).toInt()
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = bgColor
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
paint.color = Color.WHITE
paint.textSize = sizePx * 0.42f
paint.textAlign = Paint.Align.CENTER
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val metrics = paint.fontMetrics
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
return bm
}
private fun reloadSavedProfileContacts(dest: DestinationOption) {
// Use activity scope so the reload continues after sheet dismisses
requireActivity().lifecycleScope.launch(Dispatchers.IO) {
try {
if (dest.isBml) {
val bmlSess = app.bmlSession ?: return@launch
val fresh = BmlLoginFlow().fetchContacts(bmlSess)
val existing = viewModel.contacts.value ?: emptyList()
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
viewModel.contacts.postValue(merged)
ContactsCache.saveBml(requireContext(), fresh)
} else {
val profile = dest.mibProfile ?: return@launch
val mibSess = app.mibSession ?: return@launch
app.mibLoginFlow.switchProfile(mibSess, profile)
val fresh = MibContactsClient().fetchContacts(mibSess)
.map { it.copy(profileId = profile.profileId) }
val existing = viewModel.contacts.value ?: emptyList()
val merged = existing.filter { it.profileId != profile.profileId } + fresh
viewModel.contacts.postValue(merged)
val allMib = merged.filter { it.benefCategoryId != "BML" }
ContactsCache.save(requireContext(), allMib, viewModel.contactCategories.value ?: emptyList())
}
} catch (_: Exception) { }
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
// BML's internal UUID for MIB bank — used as the "swift" field when saving DOT contacts
private const val MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A"
}
}
@@ -57,6 +57,10 @@ class ContactsFragment : Fragment() {
override fun onTabReselected(tab: TabLayout.Tab) {}
})
binding.fabAddContact.setOnClickListener {
AddContactSheetFragment().show(childFragmentManager, "add_contact")
}
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
categories = cats
rebuildTabs(cats)
@@ -272,7 +272,8 @@ class HomeActivity : AppCompatActivity() {
if (seenCategories.add(cat.id)) categories.add(cat)
}
for (contact in contactsClient.fetchContacts(session)) {
if (seenContacts.add(contact.benefNo)) contacts.add(contact)
if (seenContacts.add(contact.benefNo))
contacts.add(contact.copy(profileId = profile.profileId))
}
} catch (_: Exception) { /* profile has no contacts access */ }
}
@@ -25,6 +25,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
@@ -44,6 +45,7 @@ class TransferFragment : Fragment() {
private var selectedAccount: MibAccount? = null
private val session get() = (requireActivity().application as BasedBankApp).mibSession
private val bmlSession get() = (requireActivity().application as BasedBankApp).bmlSession
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
@@ -154,25 +156,55 @@ class TransferFragment : Fragment() {
return
}
val sess = session
if (sess == null) {
val mibSess = session
val bmlSess = bmlSession
if (mibSess == null && bmlSess == null) {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val isBmlSource = selectedAccount?.profileType?.startsWith("BML") == true
binding.tilTo.isEnabled = false
viewLifecycleOwner.lifecycleScope.launch {
var errorMsg: String? = null
val info = withContext(Dispatchers.IO) {
try {
MibTransferClient().lookup(sess, accountNumber)
} catch (e: MibLookupException) {
errorMsg = e.message
null
} catch (_: Exception) {
errorMsg = getString(R.string.transfer_account_not_found)
null
if (isBmlSource && bmlSess != null) {
// BML source: prefer BML validate, fall back to MIB IPS
val bmlResult = try { BmlLoginFlow().validateAccount(bmlSess, accountNumber) } catch (_: Exception) { null }
if (bmlResult != null) {
val bankId = when (bmlResult.trnType) {
"IAT" -> "MALBMVMV"
else -> bmlResult.agnt ?: bmlResult.account
}
MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId)
} else if (mibSess != null) {
try { MibTransferClient().lookup(mibSess, accountNumber) }
catch (e: MibLookupException) { errorMsg = e.message; null }
catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null }
} else {
errorMsg = getString(R.string.transfer_account_not_found); null
}
} else {
// MIB source (or no preference): prefer MIB, fall back to BML validate
if (mibSess != null) {
try { MibTransferClient().lookup(mibSess, accountNumber) }
catch (e: MibLookupException) { errorMsg = e.message; null }
catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null }
} else {
// MIB not available, try BML
val bmlResult = try { BmlLoginFlow().validateAccount(bmlSess!!, accountNumber) } catch (_: Exception) { null }
if (bmlResult != null) {
val bankId = when (bmlResult.trnType) {
"IAT" -> "MALBMVMV"
else -> bmlResult.agnt ?: bmlResult.account
}
MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId)
} else {
errorMsg = getString(R.string.transfer_account_not_found); null
}
}
}
}
binding.tilTo.isEnabled = true