BML account lookup and add new contacts

This commit is contained in:
2026-05-14 05:18:03 +05:00
parent 94d74db4dc
commit 5805b4cb51
14 changed files with 994 additions and 70 deletions

View File

@@ -203,6 +203,107 @@ class BmlLoginFlow {
} catch (_: Exception) { "" }
}
fun validateAccount(session: BmlSession, input: String): BmlAccountValidation? {
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/validate/account/$input")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val json = resp.body?.string() ?: return null
resp.close()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
val payload = root.optJSONObject("payload") ?: return null
val trnType = payload.optString("trnType", "")
val validationType = payload.optString("validationType", "")
if (validationType == "alias") {
val cdtrAcct = payload.optJSONObject("CdtrAcct") ?: return null
BmlAccountValidation(
trnType = trnType,
validationType = validationType,
account = cdtrAcct.optString("Acct"),
originalInput = input,
name = payload.optString("contact_name").trim(),
alias = null,
currency = payload.optString("currency", "MVR"),
agnt = cdtrAcct.optString("FinInstnId").takeIf { it.isNotBlank() }
)
} else {
BmlAccountValidation(
trnType = trnType,
validationType = validationType,
account = payload.optString("account"),
originalInput = input,
name = payload.optString("name"),
alias = payload.optString("alias").takeIf { it.isNotBlank() && it != "null" },
currency = payload.optString("currency", "MVR")
)
}
} catch (_: Exception) { null }
}
fun verifyMibAccount(session: BmlSession, account: String): BmlAccountValidation? {
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/favara/account-verification/$account/MIB")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val json = resp.body?.string() ?: return null
resp.close()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
BmlAccountValidation(
trnType = "DOT",
validationType = "MIB",
account = root.optString("account"),
originalInput = account,
name = root.optString("name"),
alias = null,
currency = "MVR",
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
)
} catch (_: Exception) { null }
}
fun saveContact(
session: BmlSession,
contactType: String,
account: String,
alias: String,
currency: String? = null,
name: String? = null,
swift: String? = null
): Boolean {
val bodyObj = JSONObject().apply {
put("contact_type", contactType)
put("account", account)
put("alias", alias)
if (currency != null) put("currency", currency)
if (name != null) put("name", name)
if (swift != null) put("swift", swift)
}
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/contacts")
.post(bodyObj.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val json = resp.body?.string() ?: return false
resp.close()
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
}
fun fetchContacts(session: BmlSession): List<MibBeneficiary> {
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute()
val json = resp.body?.string() ?: return emptyList()

View File

@@ -5,6 +5,17 @@ data class BmlSession(
val deviceId: String
)
data class BmlAccountValidation(
val trnType: String, // IAT, QTR, DOT
val validationType: String, // BML, alias, MIB
val account: String, // resolved account number
val originalInput: String, // original user input (alias/Favara for QTR)
val name: String,
val alias: String?,
val currency: String,
val agnt: String? = null // BIC for DOT (MIB account on BML)
)
data class BmlForeignLimit(
val type: String,
val used: Double,

View File

@@ -126,6 +126,62 @@ class MibContactsClient {
}
}
fun createContact(
session: MibSession,
benefType: String, // "I" = MIB internal, "L" = local/BML
bankNo: Int, // 2 = MIB, 3 = BML
benefAccount: String,
benefName: String,
nickName: String,
transferCy: String = "462",
categoryId: String = "0",
imageBase64: String = ""
): Boolean {
val body = FormBody.Builder()
.add("benefType", benefType)
.add("imageSet", if (imageBase64.isNotBlank()) "1" else "0")
.add("benefAccount", benefAccount)
.add("benefIban", "")
.add("benefName", benefName)
.add("nickName", nickName)
.add("transferCy", transferCy)
.add("transferCySwift", "840")
.add("benefAddress", "")
.add("benefCity", "")
.add("benefCountry", "4")
.add("benefBankSwift", "")
.add("bankNo", bankNo.toString())
.add("benefBankName", "")
.add("benefBankBranch", "")
.add("benefBankAddress", "")
.add("benefBankCity", "")
.add("benefBankCountry", "4")
.add("intBankSwift", "")
.add("intBankName", "")
.add("intBankAddress", "")
.add("intBankBranch", "")
.add("intBankCity", "")
.add("intBankCountry", "4")
.add("categoryId", categoryId)
.add("email", "")
.add("contactNumber", "")
.add("website", "")
.add("image", imageBase64)
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/createLocalBeneficiary")
.post(body)
.withSessionHeaders(session)
.header("Referer", "$BASE_WV_URL/beneficiary/createNew")
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return@use false
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
}
}
fun fetchProfileImageBase64(session: MibSession, imageHash: String): String? {
val body = FormBody.Builder()
.add("imageHash", imageHash)

View File

@@ -232,7 +232,8 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc"),
profileImageHash = profile.customerImage,
loginTag = loginTag
loginTag = loginTag,
profileId = profile.profileId
)
)
}

View File

@@ -32,7 +32,8 @@ data class MibAccount(
val mvrBalance: String,
val statusDesc: String,
val profileImageHash: String?,
val loginTag: String = ""
val loginTag: String = "",
val profileId: String = "" // MIB profile ID; empty for BML accounts
)
data class MibBeneficiaryCategory(
@@ -53,7 +54,8 @@ data class MibBeneficiary(
val benefStatus: String,
val transferCyDesc: String,
val customerImgHash: String?,
val benefCategoryId: String // "0" = uncategorized
val benefCategoryId: String, // "0" = uncategorized
val profileId: String = "" // MIB profile ID; empty for BML contacts
)
data class MibIpsAccountInfo(

View File

@@ -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"
}
}

View File

@@ -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)

View File

@@ -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 */ }
}

View File

@@ -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

View File

@@ -27,6 +27,7 @@ object AccountCache {
put("mvrBalance", acc.mvrBalance)
put("statusDesc", acc.statusDesc)
put("loginTag", acc.loginTag)
put("profileId", acc.profileId)
if (acc.profileImageHash != null) put("profileImageHash", acc.profileImageHash)
})
}
@@ -102,7 +103,8 @@ object AccountCache {
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() },
loginTag = o.optString("loginTag")
loginTag = o.optString("loginTag"),
profileId = o.optString("profileId", "")
)
}
} catch (e: Exception) {

View File

@@ -34,6 +34,7 @@ object ContactsCache {
put("transferCyDesc", c.transferCyDesc)
put("customerImgHash", c.customerImgHash ?: "")
put("benefCategoryId", c.benefCategoryId)
put("profileId", c.profileId)
})
}
prefs.putString(KEY_CONTACTS, contactsArr.toString())
@@ -69,7 +70,8 @@ object ContactsCache {
benefStatus = o.optString("benefStatus"),
transferCyDesc = o.optString("transferCyDesc", "MVR"),
customerImgHash = o.optString("customerImgHash").takeIf { it.isNotBlank() },
benefCategoryId = o.optString("benefCategoryId", "0")
benefCategoryId = o.optString("benefCategoryId", "0"),
profileId = o.optString("profileId", "")
)
}
} catch (e: Exception) {

View File

@@ -1,71 +1,87 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
android:layout_height="match_parent">
<com.google.android.material.textfield.TextInputLayout
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
app:startIconDrawable="@android:drawable/ic_menu_search"
app:boxCornerRadiusTopStart="24dp"
app:boxCornerRadiusTopEnd="24dp"
app:boxCornerRadiusBottomStart="24dp"
app:boxCornerRadiusBottomEnd="24dp">
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSearch"
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/contacts_search_hint"
android:inputType="text"
android:maxLines="1"
android:imeOptions="actionSearch" />
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
app:startIconDrawable="@android:drawable/ic_menu_search"
app:boxCornerRadiusTopStart="24dp"
app:boxCornerRadiusTopEnd="24dp"
app:boxCornerRadiusBottomStart="24dp"
app:boxCornerRadiusBottomEnd="24dp">
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/contacts_search_hint"
android:inputType="text"
android:maxLines="1"
android:imeOptions="actionSearch" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="scrollable"
app:tabGravity="start" />
</com.google.android.material.textfield.TextInputLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingVertical="4dp" />
<ProgressBar
android:id="@+id/loadingView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="visible" />
app:tabMode="scrollable"
app:tabGravity="start" />
<TextView
android:id="@+id/emptyView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/contacts_empty"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
</FrameLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingVertical="4dp"
android:paddingBottom="80dp" />
</LinearLayout>
<ProgressBar
android:id="@+id/loadingView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="visible" />
<TextView
android:id="@+id/emptyView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/contacts_empty"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabAddContact"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:contentDescription="@string/contact_add"
app:srcCompat="@android:drawable/ic_input_add" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,220 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView
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="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="@string/contact_add"
android:textAppearance="?attr/textAppearanceTitleLarge" />
<!-- Save to (destination) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilDestination"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/contact_save_to"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu">
<AutoCompleteTextView
android:id="@+id/actvDestination"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none"
android:focusable="false" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Account number / Favara ID -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/transfer_to"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
app:endIconMode="custom"
app:endIconDrawable="@android:drawable/ic_menu_search"
app:endIconContentDescription="@string/transfer_lookup_account">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1"
android:imeOptions="actionSearch" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Lookup result card -->
<androidx.cardview.widget.CardView
android:id="@+id/cardResult"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:visibility="gone"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical">
<ImageView
android:id="@+id/ivResultAvatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:scaleType="centerCrop"
android:importantForAccessibility="no" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="12dp">
<TextView
android:id="@+id/tvResultName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/tvResultBank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>
<ImageButton
android:id="@+id/btnClearResult"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:contentDescription="@string/transfer_clear_recipient" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Alias -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilAlias"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/contact_alias"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etAlias"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Currency (read-only) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilCurrency"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/contact_currency"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etCurrency"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="false"
android:inputType="none" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Image upload (MIB only) -->
<LinearLayout
android:id="@+id/layoutImage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:visibility="gone">
<ImageView
android:id="@+id/ivContactImage"
android:layout_width="48dp"
android:layout_height="48dp"
android:scaleType="centerCrop"
android:importantForAccessibility="no"
/>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPickImage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/contact_image"
style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
</LinearLayout>
<!-- Group dropdown (MIB only) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:hint="@string/contact_group"
android:visibility="gone"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu">
<AutoCompleteTextView
android:id="@+id/actvGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none"
android:focusable="false" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Save button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/contact_save"
android:enabled="false" />
<Space
android:layout_width="match_parent"
android:layout_height="24dp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@@ -123,6 +123,23 @@
<string name="recents_remove">Remove from recents</string>
<string name="contacts_tab_all">All</string>
<!-- Add Contact -->
<string name="contact_add">Add Contact</string>
<string name="contact_save_to">Save to account</string>
<string name="contact_alias">Alias / Nickname</string>
<string name="contact_currency">Currency</string>
<string name="contact_group">Group</string>
<string name="contact_no_group">No group</string>
<string name="contact_save">Save Contact</string>
<string name="contact_image">Upload Image</string>
<string name="contact_saved">Contact saved</string>
<string name="contact_save_failed">Failed to save contact</string>
<string name="contact_no_session">No bank session available</string>
<string name="contact_lookup_failed">Could not find account</string>
<string name="contact_select_destination">Select a destination account first</string>
<string name="contact_already_exists">Contact already exists: %s</string>
<string name="contact_own_account">Cannot save your own account as a contact</string>
<!-- Financing -->
<string name="financing_empty">No financing deals found</string>
<string name="financing_total">Total</string>