From b452940ed08dd267a06685a4d102bc910875aeb8 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Wed, 13 May 2026 02:15:53 +0500 Subject: [PATCH] contacts, contact picker and trnasfer account look up ui --- .../java/sh/sar/basedbank/BasedBankApp.kt | 5 + .../sh/sar/basedbank/api/mib/MibLoginFlow.kt | 16 +- .../sh/sar/basedbank/api/mib/MibModels.kt | 12 +- .../basedbank/api/mib/MibTransferClient.kt | 143 +++++++++++ .../basedbank/ui/home/ContactPickerAdapter.kt | 125 ++++++++++ .../ui/home/ContactPickerSheetFragment.kt | 222 ++++++++++++++++++ .../sar/basedbank/ui/home/TransferFragment.kt | 218 ++++++++++++++++- .../sh/sar/basedbank/util/AccountCache.kt | 4 +- .../sh/sar/basedbank/util/RecentsCache.kt | 80 +++++++ app/src/main/res/drawable/ic_contacts.xml | 9 + app/src/main/res/layout/fragment_contacts.xml | 14 +- .../main/res/layout/fragment_dashboard.xml | 33 +++ app/src/main/res/layout/fragment_transfer.xml | 110 ++++++++- app/src/main/res/layout/item_picker_row.xml | 50 ++++ .../res/layout/item_picker_section_header.xml | 12 + .../main/res/layout/sheet_contact_picker.xml | 47 ++++ app/src/main/res/values/strings.xml | 15 +- docs/mibapi/accountlookup.md | 123 ++++++++++ docs/mibapi/transfer.md | 81 +++++++ 19 files changed, 1284 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/sh/sar/basedbank/api/mib/MibTransferClient.kt create mode 100644 app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerAdapter.kt create mode 100644 app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt create mode 100644 app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt create mode 100644 app/src/main/res/drawable/ic_contacts.xml create mode 100644 app/src/main/res/layout/item_picker_row.xml create mode 100644 app/src/main/res/layout/item_picker_section_header.xml create mode 100644 app/src/main/res/layout/sheet_contact_picker.xml create mode 100644 docs/mibapi/accountlookup.md create mode 100644 docs/mibapi/transfer.md diff --git a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt index f343e77..faff505 100644 --- a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt +++ b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt @@ -4,6 +4,7 @@ import android.app.Application import androidx.appcompat.app.AppCompatDelegate import com.google.android.material.color.DynamicColors import sh.sar.basedbank.api.mib.MibAccount +import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.api.mib.MibProfile import sh.sar.basedbank.api.mib.MibSession @@ -15,6 +16,10 @@ class BasedBankApp : Application() { var mibSession: MibSession? = null var mibProfiles: List = emptyList() + val mibLoginFlow by lazy { + MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE)) + } + override fun onCreate() { super.onCreate() DynamicColors.applyToActivitiesIfAvailable(this) diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt index 21e574f..ca37443 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt @@ -263,7 +263,8 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) { currentBalance = a.optString("currentBalance"), blockedAmount = a.optString("blockedAmount"), mvrBalance = a.optString("mvrBalance"), - statusDesc = a.optString("statusDesc") + statusDesc = a.optString("statusDesc"), + profileImageHash = profile.customerImage ) ) } @@ -283,11 +284,22 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) { name = p.optString("name"), cifType = p.optString("cifType"), profileType = p.optString("profileType"), - color = p.optString("color") + color = p.optString("color"), + customerImage = p.optString("customerImage").takeIf { it.isNotBlank() } ) } } + /** Fetches a profile image via P41. Returns base64 JPEG string, or null if not found. */ + fun fetchProfileImage(session: MibSession, imageHash: String): String? { + val payload = baseData(session, "P41").apply { + put("imageHash", imageHash) + } + val resp = doRequest(session, payload, "n") + if (!resp.optBoolean("success", false)) return null + return resp.optString("profileImage").takeIf { it.isNotBlank() } + } + private fun post(body: FormBody): String { val request = Request.Builder() .url(BASE_URL) diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt index ba08594..4a73572 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt @@ -15,7 +15,8 @@ data class MibProfile( val name: String, val cifType: String, val profileType: String, - val color: String + val color: String, + val customerImage: String? ) data class MibAccount( @@ -29,7 +30,8 @@ data class MibAccount( val currentBalance: String, val blockedAmount: String, val mvrBalance: String, - val statusDesc: String + val statusDesc: String, + val profileImageHash: String? ) data class MibBeneficiaryCategory( @@ -53,6 +55,12 @@ data class MibBeneficiary( val benefCategoryId: String // "0" = uncategorized ) +data class MibIpsAccountInfo( + val accountName: String, + val accountNumber: String, + val bankId: String +) + data class MibFinanceDeal( val dealNo: String, val productDesc: String, diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibTransferClient.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibTransferClient.kt new file mode 100644 index 0000000..a765aa2 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibTransferClient.kt @@ -0,0 +1,143 @@ +package sh.sar.basedbank.api.mib + +import android.util.Log +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +class MibLookupException(message: String) : Exception(message) + +class MibTransferClient { + + private val TAG = "MibTransferClient" + private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv" + + private val client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .build() + + private fun cookieHeader(session: MibSession) = + "mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " + + "mbnonce=${session.nonceGenerator}; time-tracker=597" + + private fun Request.Builder.withWvHeaders(session: MibSession): Request.Builder = this + .header("Cookie", cookieHeader(session)) + .header( + "User-Agent", + "Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36" + ) + .header("X-Requested-With", "XMLHttpRequest") + .header("Accept", "*/*") + .header("Origin", BASE_WV_URL) + .header("Referer", "$BASE_WV_URL/transfer/quick") + + /** + * Routes the lookup to the correct endpoint based on input format: + * + * - 15 digits starting with 7 → IPS account lookup (BML / local bank) + * - 17 digits starting with 9 → MIB internal account name lookup + * - 7 digits starting with 7 or 9, + * A + 6 digits, + * or email address → Favara alias lookup + */ + fun lookup(session: MibSession, input: String): MibIpsAccountInfo { + val trimmed = input.trim() + return when { + trimmed.matches(Regex("^7\\d{12}$")) -> lookupIpsAccount(session, trimmed) + trimmed.matches(Regex("^9\\d{16}$")) -> lookupAccountName(session, trimmed) + trimmed.matches(Regex("^[79]\\d{6}$")) -> lookupAlias(session, trimmed) + trimmed.matches(Regex("^[Aa]\\d{6}$")) -> lookupAlias(session, trimmed) + trimmed.contains("@") -> lookupAlias(session, trimmed) + else -> throw MibLookupException("Unrecognized account number format") + } + } + + /** BML / local bank: 15-digit account starting with 7. */ + private fun lookupIpsAccount(session: MibSession, accountNumber: String): MibIpsAccountInfo { + val body = FormBody.Builder().add("benefAccount", accountNumber).build() + val request = Request.Builder() + .url("$BASE_WV_URL/AjaxAlias/getIPSAccount") + .post(body) + .withWvHeaders(session) + .build() + + return client.newCall(request).execute().use { response -> + Log.d(TAG, "lookupIpsAccount: HTTP ${response.code}") + val bodyStr = response.body?.string() ?: "" + val json = try { JSONObject(bodyStr) } catch (_: Exception) { null } + if (!response.isSuccessful || json == null || !json.optBoolean("success")) { + throw MibLookupException( + json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() } + ?: "Request failed (${response.code})" + ) + } + MibIpsAccountInfo( + accountName = json.optString("accountName").trim(), + accountNumber = accountNumber, + bankId = json.optString("bankBic") + ) + } + } + + /** MIB internal: 17-digit account starting with 9. */ + private fun lookupAccountName(session: MibSession, accountNumber: String): MibIpsAccountInfo { + val body = FormBody.Builder().add("accountNo", accountNumber).build() + val request = Request.Builder() + .url("$BASE_WV_URL/ajaxBeneficiary/getAccountName") + .post(body) + .withWvHeaders(session) + .build() + + return client.newCall(request).execute().use { response -> + Log.d(TAG, "lookupAccountName: HTTP ${response.code}") + val bodyStr = response.body?.string() ?: "" + val json = try { JSONObject(bodyStr) } catch (_: Exception) { null } + if (!response.isSuccessful || json == null || !json.optBoolean("success")) { + throw MibLookupException( + json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() } + ?: "Request failed (${response.code})" + ) + } + // accountName may be at root or inside a "data" object + val name = json.optString("accountName").takeIf { it.isNotBlank() } + ?: json.optJSONObject("data")?.optString("accountName") ?: "" + MibIpsAccountInfo( + accountName = name.trim(), + accountNumber = accountNumber, + bankId = "MADVMVMV" // MIB + ) + } + } + + /** Favara alias: 7-digit shortcode (7/9 prefix), A-format ID, or email. */ + private fun lookupAlias(session: MibSession, aliasName: String): MibIpsAccountInfo { + val body = FormBody.Builder().add("aliasName", aliasName).build() + val request = Request.Builder() + .url("$BASE_WV_URL/AjaxAlias/getAlias") + .post(body) + .withWvHeaders(session) + .build() + + return client.newCall(request).execute().use { response -> + Log.d(TAG, "lookupAlias: HTTP ${response.code}") + val bodyStr = response.body?.string() ?: "" + val json = try { JSONObject(bodyStr) } catch (_: Exception) { null } + if (!response.isSuccessful || json == null || !json.optBoolean("success")) { + throw MibLookupException( + json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() } + ?: "Request failed (${response.code})" + ) + } + val data = json.getJSONObject("data") + val cdtrAcct = data.getJSONObject("CdtrAcct") + MibIpsAccountInfo( + accountName = data.optString("BfyNm").trim(), + accountNumber = cdtrAcct.optString("Acct"), + bankId = cdtrAcct.optString("FinInstnId") + ) + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerAdapter.kt new file mode 100644 index 0000000..3139f6f --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerAdapter.kt @@ -0,0 +1,125 @@ +package sh.sar.basedbank.ui.home + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import sh.sar.basedbank.databinding.ItemPickerRowBinding +import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding + +class ContactPickerAdapter( + private val onItemClick: (accountNumber: String, label: String) -> Unit, + private val onSameAsFrom: () -> Unit, + private val onImageNeeded: ((hash: String) -> Unit)? = null, + private val onItemLongClick: ((accountNumber: String, anchor: android.view.View) -> Boolean)? = null +) : RecyclerView.Adapter() { + + sealed class PickerItem { + data class Header(val title: String) : PickerItem() + data class Row( + val accountNumber: String, + val displayName: String, + val subtitle: String, + val colorHex: String, + val isSameAsFrom: Boolean = false, + val isManualEntry: Boolean = false, + val imageHash: String? = null + ) : PickerItem() + } + + private var items: List = emptyList() + private val imageCache = mutableMapOf() + + fun updateImage(hash: String, bitmap: Bitmap) { + imageCache[hash] = bitmap + val idx = items.indexOfFirst { it is PickerItem.Row && it.imageHash == hash } + if (idx >= 0) notifyItemChanged(idx) + } + + fun submitList(newItems: List) { + items = newItems + notifyDataSetChanged() + } + + companion object { + private const val TYPE_HEADER = 0 + private const val TYPE_ROW = 1 + } + + override fun getItemViewType(position: Int) = when (items[position]) { + is PickerItem.Header -> TYPE_HEADER + is PickerItem.Row -> TYPE_ROW + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + TYPE_HEADER -> HeaderVH(ItemPickerSectionHeaderBinding.inflate(inflater, parent, false)) + else -> RowVH(ItemPickerRowBinding.inflate(inflater, parent, false)) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (val item = items[position]) { + is PickerItem.Header -> (holder as HeaderVH).bind(item) + is PickerItem.Row -> (holder as RowVH).bind(item) + } + } + + override fun getItemCount() = items.size + + inner class HeaderVH(private val binding: ItemPickerSectionHeaderBinding) : + RecyclerView.ViewHolder(binding.root) { + fun bind(item: PickerItem.Header) { + binding.tvHeader.text = item.title + } + } + + inner class RowVH(private val binding: ItemPickerRowBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(item: PickerItem.Row) { + binding.tvPrimary.text = item.displayName + binding.tvSecondary.text = item.subtitle + + val cached = item.imageHash?.let { imageCache[it] } + if (cached != null) { + binding.ivIcon.setImageBitmap(cached) + } else { + val iconChar = if (item.isManualEntry) "→" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?" + val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex + binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context)) + if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash) + } + + binding.root.alpha = if (item.isSameAsFrom) 0.4f else 1.0f + binding.root.setOnClickListener { + if (item.isSameAsFrom) onSameAsFrom() + else onItemClick(item.accountNumber, item.displayName) + } + binding.root.setOnLongClickListener { view -> + onItemLongClick?.invoke(item.accountNumber, view) ?: false + } + } + + private fun makeInitialsBitmap(letter: String, colorHex: String, context: Context): Bitmap { + val sizePx = (context.resources.displayMetrics.density * 44).toInt() + val bgColor = try { Color.parseColor(colorHex) } catch (e: 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 metrics = paint.fontMetrics + canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint) + return bm + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt new file mode 100644 index 0000000..4769f87 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt @@ -0,0 +1,222 @@ +package sh.sar.basedbank.ui.home + +import android.graphics.BitmapFactory +import android.os.Bundle +import android.util.Base64 +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.tabs.TabLayout +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sh.sar.basedbank.BasedBankApp +import sh.sar.basedbank.R +import sh.sar.basedbank.api.mib.MibContactsClient +import android.widget.PopupMenu +import sh.sar.basedbank.databinding.SheetContactPickerBinding +import sh.sar.basedbank.util.RecentsCache + +class ContactPickerSheetFragment : BottomSheetDialogFragment() { + + private var _binding: SheetContactPickerBinding? = null + private val binding get() = _binding!! + private val viewModel: HomeViewModel by activityViewModels() + + private lateinit var adapter: ContactPickerAdapter + // null = All, RECENTS_TAG = Recents, else = category id + private var activeCategoryId: String? = RECENTS_TAG + private val pendingHashes = mutableSetOf() + private val profileImageHashes = mutableSetOf() + private val app get() = requireActivity().application as BasedBankApp + private val session get() = app.mibSession + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = SheetContactPickerBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val selectedAccountNumber = arguments?.getString(ARG_FROM_ACCOUNT) ?: "" + + adapter = ContactPickerAdapter( + onItemClick = { accountNumber, label -> + setFragmentResult(REQUEST_KEY, bundleOf( + KEY_ACCOUNT_NUMBER to accountNumber, + KEY_LABEL to label + )) + dismiss() + }, + onSameAsFrom = {}, + onImageNeeded = { hash -> fetchImage(hash) }, + onItemLongClick = { accountNumber, anchor -> + if (activeCategoryId == RECENTS_TAG) { + val menu = PopupMenu(requireContext(), anchor) + menu.menu.add(getString(R.string.recents_remove)) + menu.setOnMenuItemClickListener { + RecentsCache.remove(requireContext(), accountNumber) + rebuildList(selectedAccountNumber) + true + } + menu.show() + true + } else false + } + ) + + binding.sheetRecyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.sheetRecyclerView.adapter = adapter + + binding.etSheetSearch.addTextChangedListener { rebuildList(selectedAccountNumber) } + + // Tabs: Recents | All | + binding.sheetCategoryTabs.addTab( + binding.sheetCategoryTabs.newTab().setText(R.string.contacts_tab_recents).apply { tag = RECENTS_TAG } + ) + binding.sheetCategoryTabs.addTab( + binding.sheetCategoryTabs.newTab().setText(R.string.contacts_tab_all).apply { tag = null } + ) + binding.sheetCategoryTabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + activeCategoryId = tab.tag as? String + rebuildList(selectedAccountNumber) + } + override fun onTabUnselected(tab: TabLayout.Tab) {} + override fun onTabReselected(tab: TabLayout.Tab) {} + }) + + viewModel.contactCategories.observe(viewLifecycleOwner) { cats -> + // Remove all tabs after "All" (index 1) and re-add categories + while (binding.sheetCategoryTabs.tabCount > 2) binding.sheetCategoryTabs.removeTabAt(2) + for (cat in cats) { + binding.sheetCategoryTabs.addTab( + binding.sheetCategoryTabs.newTab().setText(cat.categoryName).apply { tag = cat.id } + ) + } + rebuildList(selectedAccountNumber) + } + + viewModel.contacts.observe(viewLifecycleOwner) { rebuildList(selectedAccountNumber) } + viewModel.accounts.observe(viewLifecycleOwner) { rebuildList(selectedAccountNumber) } + } + + private fun rebuildList(fromNumber: String) { + val search = binding.etSheetSearch.text?.toString()?.trim() ?: "" + val items = mutableListOf() + + if (activeCategoryId == RECENTS_TAG) { + val recents = RecentsCache.load(requireContext()) + val filtered = if (search.isBlank()) recents else recents.filter { + it.displayName.contains(search, ignoreCase = true) || it.accountNumber.contains(search) + } + for (r in filtered) { + if (r.isProfileImage && r.imageHash != null) profileImageHashes.add(r.imageHash) + items.add(ContactPickerAdapter.PickerItem.Row( + accountNumber = r.accountNumber, + displayName = r.displayName, + subtitle = r.subtitle, + colorHex = r.colorHex, + isSameAsFrom = r.accountNumber == fromNumber, + imageHash = r.imageHash + )) + } + adapter.submitList(items) + return + } + + val accounts = viewModel.accounts.value ?: emptyList() + val contacts = viewModel.contacts.value ?: emptyList() + + if (activeCategoryId == null) { + val filtered = if (search.isBlank()) accounts else accounts.filter { + it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search) + } + if (filtered.isNotEmpty()) { + items.add(ContactPickerAdapter.PickerItem.Header(getString(R.string.transfer_my_accounts))) + for (acc in filtered) { + if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash) + items.add(ContactPickerAdapter.PickerItem.Row( + accountNumber = acc.accountNumber, + displayName = acc.accountBriefName, + subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}", + colorHex = "#FE860E", + isSameAsFrom = acc.accountNumber == fromNumber, + imageHash = acc.profileImageHash + )) + } + } + } + + val filteredContacts = contacts.filter { contact -> + val matchesCat = activeCategoryId == null || contact.benefCategoryId == activeCategoryId + val matchesSearch = search.isBlank() || + contact.benefNickName.contains(search, ignoreCase = true) || + contact.benefName.contains(search, ignoreCase = true) || + contact.benefAccount.contains(search) + matchesCat && matchesSearch + } + + if (filteredContacts.isNotEmpty()) { + if (activeCategoryId == null) { + items.add(ContactPickerAdapter.PickerItem.Header(getString(R.string.nav_contacts))) + } + for (contact in filteredContacts) { + items.add(ContactPickerAdapter.PickerItem.Row( + accountNumber = contact.benefAccount, + displayName = contact.benefNickName, + subtitle = "${contact.benefBankName} · ${contact.benefAccount}", + colorHex = contact.bankColor, + isSameAsFrom = contact.benefAccount == fromNumber, + imageHash = contact.customerImgHash + )) + } + } + + adapter.submitList(items) + } + + private fun fetchImage(hash: String) { + if (!pendingHashes.add(hash)) return + val sess = session ?: return + lifecycleScope.launch(Dispatchers.IO) { + try { + val base64 = if (hash in profileImageHashes) { + app.mibLoginFlow.fetchProfileImage(sess, hash) + } else { + MibContactsClient().fetchProfileImageBase64(sess, hash) + } ?: return@launch + val bytes = Base64.decode(base64, Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch + withContext(Dispatchers.Main) { + adapter.updateImage(hash, bitmap) + } + } catch (_: Exception) { + pendingHashes.remove(hash) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + const val REQUEST_KEY = "contact_picker" + const val KEY_ACCOUNT_NUMBER = "accountNumber" + const val KEY_LABEL = "label" + private const val ARG_FROM_ACCOUNT = "fromAccount" + private const val RECENTS_TAG = "__recents__" + + fun newInstance(fromAccountNumber: String) = ContactPickerSheetFragment().apply { + arguments = bundleOf(ARG_FROM_ACCOUNT to fromAccountNumber) + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index 9254f95..fea0a66 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -1,18 +1,36 @@ package sh.sar.basedbank.ui.home import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint 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.core.widget.addTextChangedListener import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R import sh.sar.basedbank.api.mib.MibAccount +import sh.sar.basedbank.api.mib.MibContactsClient +import sh.sar.basedbank.api.mib.MibIpsAccountInfo +import sh.sar.basedbank.api.mib.MibLookupException +import sh.sar.basedbank.api.mib.MibTransferClient import sh.sar.basedbank.databinding.FragmentTransferBinding import sh.sar.basedbank.databinding.ItemAccountDropdownBinding +import sh.sar.basedbank.util.RecentPick +import sh.sar.basedbank.util.RecentsCache class TransferFragment : Fragment() { @@ -21,6 +39,7 @@ class TransferFragment : Fragment() { private val viewModel: HomeViewModel by activityViewModels() private var selectedAccount: MibAccount? = null + private val session get() = (requireActivity().application as BasedBankApp).mibSession override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentTransferBinding.inflate(inflater, container, false) @@ -28,8 +47,18 @@ class TransferFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - viewModel.accounts.observe(viewLifecycleOwner) { accounts -> - setupAccountDropdown(accounts) + setupFromDropdown() + setupAccountLookup() + + childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle -> + val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener + val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: "" + prefillToFromContact(accountNumber, label) + } + + binding.btnPickContact.setOnClickListener { + val sheet = ContactPickerSheetFragment.newInstance(selectedAccount?.accountNumber ?: "") + sheet.show(childFragmentManager, "contact_picker") } binding.btnTransfer.setOnClickListener { @@ -37,21 +66,188 @@ class TransferFragment : Fragment() { } } - private fun setupAccountDropdown(accounts: List) { - val adapter = AccountDropdownAdapter(requireContext(), accounts) - binding.actvFrom.setAdapter(adapter) + private fun setupFromDropdown() { + viewModel.accounts.observe(viewLifecycleOwner) { accounts -> + val adapter = AccountDropdownAdapter(requireContext(), accounts) + binding.actvFrom.setAdapter(adapter) - if (accounts.isNotEmpty() && selectedAccount == null) { - selectedAccount = accounts[0] - binding.actvFrom.setText(accounts[0].toDisplayString(), false) + if (accounts.isNotEmpty() && selectedAccount == null) { + selectedAccount = accounts[0] + binding.actvFrom.setText(accounts[0].toDisplayString(), false) + } + + binding.actvFrom.setOnItemClickListener { _, _, position, _ -> + selectedAccount = accounts[position] + binding.actvFrom.setText(accounts[position].toDisplayString(), false) + } + } + } + + private fun setupAccountLookup() { + binding.tilTo.setEndIconOnClickListener { lookupAccount() } + + binding.btnClearToInfo.setOnClickListener { + binding.cardToInfo.visibility = View.GONE + binding.tilTo.visibility = View.VISIBLE + binding.btnPickContact.visibility = View.VISIBLE + binding.tilTo.error = null } - binding.actvFrom.setOnItemClickListener { _, _, position, _ -> - selectedAccount = accounts[position] - binding.actvFrom.setText(accounts[position].toDisplayString(), false) + binding.etTo.addTextChangedListener { + binding.tilTo.error = null + if (binding.cardToInfo.visibility == View.VISIBLE) { + binding.cardToInfo.visibility = View.GONE + binding.tilTo.visibility = View.VISIBLE + binding.btnPickContact.visibility = View.VISIBLE + } } } + private fun lookupAccount() { + val accountNumber = binding.etTo.text?.toString()?.trim() ?: "" + if (accountNumber.isBlank()) { + Toast.makeText(requireContext(), R.string.transfer_enter_account_first, Toast.LENGTH_SHORT).show() + return + } + val sess = session + if (sess == null) { + Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() + return + } + + 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 + } + } + binding.tilTo.isEnabled = true + if (info != null) { + val accounts = viewModel.accounts.value ?: emptyList() + val contacts = viewModel.contacts.value ?: emptyList() + val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber } + val matchedContact = contacts.firstOrNull { it.benefAccount == info.accountNumber } + + val displayName = matchedAcc?.accountBriefName + ?: matchedContact?.benefNickName + ?: info.accountName + val colorHex = if (matchedAcc != null) "#FE860E" else matchedContact?.bankColor ?: "#607D8B" + + binding.tvToAccountName.text = displayName + binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}" + binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex)) + binding.tilTo.visibility = View.GONE + binding.btnPickContact.visibility = View.GONE + binding.cardToInfo.visibility = View.VISIBLE + saveToRecents(info) + + when { + matchedAcc?.profileImageHash != null -> + loadToPhoto(matchedAcc.profileImageHash, isProfile = true) + matchedContact?.customerImgHash != null -> + loadToPhoto(matchedContact.customerImgHash, isProfile = false) + } + } else { + Toast.makeText(requireContext(), errorMsg, Toast.LENGTH_SHORT).show() + } + } + } + + private fun prefillToFromContact(accountNumber: String, label: String) { + binding.cardToInfo.visibility = View.GONE + binding.tilTo.visibility = View.VISIBLE + binding.btnPickContact.visibility = View.VISIBLE + binding.tilTo.error = null + binding.etTo.setText(accountNumber) + lookupAccount() + } + + private fun loadToPhoto(hash: String, isProfile: Boolean) { + val sess = session ?: return + val app = requireActivity().application as BasedBankApp + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + try { + val base64 = if (isProfile) { + app.mibLoginFlow.fetchProfileImage(sess, hash) + } else { + MibContactsClient().fetchProfileImageBase64(sess, hash) + } ?: return@launch + val bytes = Base64.decode(base64, Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch + withContext(Dispatchers.Main) { + if (_binding != null) binding.ivToPhoto.setImageBitmap(bitmap) + } + } catch (_: Exception) { } + } + } + + 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 saveToRecents(info: MibIpsAccountInfo) { + val accounts = viewModel.accounts.value ?: emptyList() + val contacts = viewModel.contacts.value ?: emptyList() + + val acc = accounts.firstOrNull { it.accountNumber == info.accountNumber } + if (acc != null) { + RecentsCache.save(requireContext(), RecentPick( + accountNumber = acc.accountNumber, + displayName = acc.accountBriefName, + subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}", + colorHex = "#FE860E", + imageHash = acc.profileImageHash, + isProfileImage = true + )) + return + } + + val contact = contacts.firstOrNull { it.benefAccount == info.accountNumber } + if (contact != null) { + RecentsCache.save(requireContext(), RecentPick( + accountNumber = contact.benefAccount, + displayName = contact.benefNickName, + subtitle = "${contact.benefBankName} · ${contact.benefAccount}", + colorHex = contact.bankColor, + imageHash = contact.customerImgHash, + isProfileImage = false + )) + return + } + + // Manual entry not in contacts/accounts — save with resolved info + RecentsCache.save(requireContext(), RecentPick( + accountNumber = info.accountNumber, + displayName = info.accountName, + subtitle = "${info.accountNumber} · ${info.bankId}", + colorHex = "#607D8B", + imageHash = null, + isProfileImage = false + )) + } + override fun onResume() { super.onResume() requireActivity().title = getString(R.string.transfer) diff --git a/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt b/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt index b0b520e..62b72b2 100644 --- a/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt @@ -25,6 +25,7 @@ object AccountCache { put("blockedAmount", acc.blockedAmount) put("mvrBalance", acc.mvrBalance) put("statusDesc", acc.statusDesc) + if (acc.profileImageHash != null) put("profileImageHash", acc.profileImageHash) }) } context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -49,7 +50,8 @@ object AccountCache { currentBalance = o.optString("currentBalance"), blockedAmount = o.optString("blockedAmount"), mvrBalance = o.optString("mvrBalance"), - statusDesc = o.optString("statusDesc") + statusDesc = o.optString("statusDesc"), + profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() } ) } } catch (e: Exception) { diff --git a/app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt b/app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt new file mode 100644 index 0000000..172cc4d --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt @@ -0,0 +1,80 @@ +package sh.sar.basedbank.util + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject + +data class RecentPick( + val accountNumber: String, + val displayName: String, + val subtitle: String, + val colorHex: String, + val imageHash: String?, + val isProfileImage: Boolean +) + +object RecentsCache { + + private const val PREFS = "recents_cache" + private const val KEY = "contact_recents" + private const val MAX = 10 + + fun save(context: Context, pick: RecentPick) { + val existing = load(context).toMutableList() + existing.removeAll { it.accountNumber == pick.accountNumber } + existing.add(0, pick) + if (existing.size > MAX) existing.subList(MAX, existing.size).clear() + + val arr = JSONArray() + for (r in existing) { + arr.put(JSONObject().apply { + put("accountNumber", r.accountNumber) + put("displayName", r.displayName) + put("subtitle", r.subtitle) + put("colorHex", r.colorHex) + if (r.imageHash != null) put("imageHash", r.imageHash) + put("isProfileImage", r.isProfileImage) + }) + } + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY, arr.toString()).apply() + } + + fun remove(context: Context, accountNumber: String) { + val updated = load(context).filter { it.accountNumber != accountNumber } + val arr = JSONArray() + for (r in updated) { + arr.put(JSONObject().apply { + put("accountNumber", r.accountNumber) + put("displayName", r.displayName) + put("subtitle", r.subtitle) + put("colorHex", r.colorHex) + if (r.imageHash != null) put("imageHash", r.imageHash) + put("isProfileImage", r.isProfileImage) + }) + } + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY, arr.toString()).apply() + } + + fun load(context: Context): List { + val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY, null) ?: return emptyList() + return try { + val arr = JSONArray(json) + (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + RecentPick( + accountNumber = o.getString("accountNumber"), + displayName = o.getString("displayName"), + subtitle = o.getString("subtitle"), + colorHex = o.getString("colorHex"), + imageHash = o.optString("imageHash").takeIf { it.isNotBlank() }, + isProfileImage = o.optBoolean("isProfileImage", false) + ) + } + } catch (_: Exception) { + emptyList() + } + } +} diff --git a/app/src/main/res/drawable/ic_contacts.xml b/app/src/main/res/drawable/ic_contacts.xml new file mode 100644 index 0000000..7798a63 --- /dev/null +++ b/app/src/main/res/drawable/ic_contacts.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_contacts.xml b/app/src/main/res/layout/fragment_contacts.xml index 60c020f..05a543f 100644 --- a/app/src/main/res/layout/fragment_contacts.xml +++ b/app/src/main/res/layout/fragment_contacts.xml @@ -6,13 +6,6 @@ android:layout_height="match_parent" android:orientation="vertical"> - - + + + + + + + + + + + + + + + - - + + android:orientation="horizontal" + android:gravity="center_vertical"> - + + + + + + + + + + + + + + android:orientation="horizontal" + android:paddingStart="12dp" + android:paddingEnd="4dp" + android:paddingVertical="12dp" + android:gravity="center_vertical"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_picker_section_header.xml b/app/src/main/res/layout/item_picker_section_header.xml new file mode 100644 index 0000000..b576c49 --- /dev/null +++ b/app/src/main/res/layout/item_picker_section_header.xml @@ -0,0 +1,12 @@ + + diff --git a/app/src/main/res/layout/sheet_contact_picker.xml b/app/src/main/res/layout/sheet_contact_picker.xml new file mode 100644 index 0000000..2e1e996 --- /dev/null +++ b/app/src/main/res/layout/sheet_contact_picker.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 702fb28..a09ba99 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,6 +74,7 @@ Work in progress + Pending Finances MVR Total USD Total Card Support @@ -94,14 +95,26 @@ Available Balance + Quick Transfer + Contacts From account - To account number + Account Number or Favara ID + My Accounts + This is the same account as the sender + Look up account + Clear recipient + Pick contact + Enter an account number first + Account not found + Session unavailable — please re-login Amount Remarks No contacts found Search contacts + Recents + Remove from recents All diff --git a/docs/mibapi/accountlookup.md b/docs/mibapi/accountlookup.md new file mode 100644 index 0000000..8b0fdad --- /dev/null +++ b/docs/mibapi/accountlookup.md @@ -0,0 +1,123 @@ +# MIB Account Lookup Routing + +Before initiating a transfer, the recipient input must be resolved to a verified account name and +account number. Three different endpoints are used depending on the format of the input. + +## Input Format Routing + +| Input format | Endpoint | Body field | +|-------------------------------------------|---------------------------------------|-----------------| +| Starts with `7`, exactly 13 digits | `AjaxAlias/getIPSAccount` | `benefAccount` | +| Starts with `9`, exactly 17 digits | `ajaxBeneficiary/getAccountName` | `accountNo` | +| Starts with `7` or `9`, exactly 7 digits | `AjaxAlias/getAlias` | `aliasName` | +| Starts with `A` followed by 6 digits | `AjaxAlias/getAlias` | `aliasName` | +| Email address (contains `@`) | `AjaxAlias/getAlias` | `aliasName` | + +All endpoints share the same WebView session auth (see `contacts.md` for cookie format) and use +`Referer: https://faisamobilex-wv.mib.com.mv/transfer/quick`. + +--- + +## Endpoint Details + +### 1. IPS Account Lookup — Local / BML accounts (13 digits, starts with 7) + +**POST** `https://faisamobilex-wv.mib.com.mv/AjaxAlias/getIPSAccount` + +Body: `benefAccount=7700000000000` (13 digits) + +**Success response:** +```json +{ + "success": true, + "responseCode": "2", + "reasonCode": "201", + "reasonText": "Request Successful. Account Found", + "accountName": "ACCOUNT HOLDER NAME", + "bankBic": "MALBMVMV" +} +``` + +Fields used: +- `accountName` — account holder name +- `bankBic` — bank SWIFT/BIC code + +The account number is already known from the input; it is not returned in the response. + +--- + +### 2. MIB Internal Account Name Lookup — MIB accounts (17 digits, starts with 9) + +**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getAccountName` + +Body: `accountNo=90100000000000000` (17 digits) + +**Success response** (exact structure to be confirmed): +```json +{ + "success": true, + "responseCode": "1", + "reasonText": "Account found", + "accountName": "ACCOUNT HOLDER NAME" +} +``` + +Fields used: +- `accountName` — account holder name (check at root level or inside `data` object) + +The account number is already known from the input; bank is always MIB (`MADVMVMV`). + +--- + +### 3. Favara Alias Lookup — Shortcodes, A-IDs, emails + +**POST** `https://faisamobilex-wv.mib.com.mv/AjaxAlias/getAlias` + +Body: `aliasName=` + +Accepted alias formats: +- `7` or `9` followed by 6 digits → e.g. `7012345`, `9198026` +- `A` followed by 6 digits → e.g. `A123456` +- Email address → e.g. `user@example.com` + +**Success response:** +```json +{ + "success": true, + "responseCode": "2", + "reasonCode": "203", + "reasonText": " Favara ID found", + "data": { + "TxId": "BANK00001", + "CdtrAcct": { + "Acct": "90100000000000000", + "FinInstnId": "MADVMVMV" + }, + "BfyNm": "Account Holder Name", + "RegDtTm": "2023-01-01T00:00:00" + } +} +``` + +Fields used from `data`: +- `BfyNm` — beneficiary name (trim whitespace) +- `CdtrAcct.Acct` — resolved account number to use for the transfer +- `CdtrAcct.FinInstnId` — bank institution ID + +--- + +## Error Handling + +All three endpoints return `"success": false` on failure with a human-readable `reasonText`: + +```json +{ + "success": false, + "responseCode": "0", + "reasonText": "Account not found" +} +``` + +- Always show `reasonText` directly to the user as the error message. +- For non-200 HTTP responses, also attempt to parse `reasonText` from the body before falling back to a generic error. +- If the input does not match any known format, reject it client-side before making any request. diff --git a/docs/mibapi/transfer.md b/docs/mibapi/transfer.md new file mode 100644 index 0000000..583bd53 --- /dev/null +++ b/docs/mibapi/transfer.md @@ -0,0 +1,81 @@ +# MIB Transfer API + +Transfer endpoints are served from the MIB WebView subdomain, using the same session-cookie auth as +financing and contacts. + +## Authentication + +``` +Cookie: mbmodel=IOS-1.0; xxid=; IBSID=; mbnonce=; time-tracker=597 +``` + +All AJAX POST requests also require: + +``` +X-Requested-With: XMLHttpRequest +Origin: https://faisamobilex-wv.mib.com.mv +Referer: https://faisamobilex-wv.mib.com.mv/transfer/quick +Content-Type: application/x-www-form-urlencoded; charset=UTF-8 +``` + +--- + +## Endpoints + +### 1. Look Up Recipient by Favara Alias + +Resolves a Favara ID (alias) to the account holder name and account number before initiating a transfer. + +**POST** `https://faisamobilex-wv.mib.com.mv/AjaxAlias/getAlias` + +**Request body (form-urlencoded):** + +| Field | Description | +|-------------|------------------------------------------| +| `aliasName` | The recipient's Favara ID / alias number | + +**Success response (`responseCode: "2"`):** + +```json +{ + "success": true, + "responseCode": "2", + "reasonCode": "203", + "reasonText": " Favara ID found", + "data": { + "TxId": "BANK00001", + "CreDtTm": "...", + "Resp": { + "Rslt": true, + "RsltDtls": null + }, + "CdtrAcct": { + "Acct": "90100000000000000", + "FinInstnId": "MADVMVMV" + }, + "BfyNm": "Account Holder Name", + "RegDtTm": "2023-01-01T00:00:00" + } +} +``` + +**Not found / error response:** + +```json +{ + "success": false, + "responseCode": "0", + "reasonCode": "400", + "reasonText": "Alias not found" +} +``` + +Key fields from `data`: +- `BfyNm` — beneficiary full name (trim whitespace) +- `CdtrAcct.Acct` — resolved account number to use for the transfer +- `CdtrAcct.FinInstnId` — bank institution ID (e.g. `MADVMVMV`, `MALBMVMV`) + +**Notes:** +- Use `success` (not `responseCode`) to determine if the lookup succeeded. +- Show `BfyNm` + `CdtrAcct.Acct` to the user as confirmation before proceeding. +- The `reasonText` from error responses should be shown directly to the user.