Compare commits

...
27 Commits
Author SHA1 Message Date
shihaam a37354fc82 release version 1.0.27
Auto Tag on Version Change / check-version (push) Successful in 4s
Build and Release APK / build (push) Successful in 4m5s
2026-09-25 07:03:40 +05:00
shihaam 66568a464a share contact account numbers and PayMV QR for contacts
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-25 06:53:15 +05:00
shihaam 5210e6207f redegsigned contacts page: removed delete and edit buttons from contacts list, made a new contacts sheet and moved buttons there
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-25 06:35:39 +05:00
shihaam 369c6e4a37 redegsigned OTP page and show next TOTP code
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-25 06:18:09 +05:00
shihaam fbedc631c5 prep for fdroid
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-25 05:53:53 +05:00
shihaam 7d6c192a63 update ci to use owner-repo name as title and apk name
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-25 05:25:09 +05:00
shihaam e7f7590654 release version 1.0.25
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m17s
2026-09-25 04:45:25 +05:00
shihaam a048be939c Merge branch 'fix/show-currency-of-other-account-in-transfer-page'
Auto Tag on Version Change / check-version (push) Failing after 2s
2026-09-25 04:42:36 +05:00
shihaam 102a986201 Merge branch 'fix/contact-add-page-account-number-enter-behaviour'
Auto Tag on Version Change / check-version (push) Failing after 3s
# Conflicts:
#	app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt
2026-09-25 04:37:22 +05:00
shihaam 440db13a0f also add search button to keyboard in transfer page to feild and move focus to amount feild on search search or to field fill 2026-09-25 04:35:31 +05:00
shihaam 0dc81fef62 Merge branch 'feat/hide-accounts'
Auto Tag on Version Change / check-version (push) Failing after 3s
2026-09-25 04:21:08 +05:00
shihaam 7e52b510a1 disable unnessary api requests on saving profile/account disable and also disable all accounts for a profile when that profile is disabled 2026-09-25 04:20:40 +05:00
shihaam 0d7833af5c Merge branch 'feat/search-saved-contacts-in-account-number-field'
Auto Tag on Version Change / check-version (push) Failing after 2s
2026-09-25 04:05:05 +05:00
shihaam 9e45e36c0a Merge branch 'feat/login-sort-order'
Auto Tag on Version Change / check-version (push) Failing after 4s
2026-09-25 04:02:12 +05:00
shihaam c883b6b521 added margin between icon and edge of selection in login list 2026-09-25 03:52:07 +05:00
shihaam 5dc37aadf0 update docs 2026-09-25 03:46:35 +05:00
shihaam 1e3cf3420b login sorting fixed 2026-09-25 03:43:58 +05:00
shihaam d98dcfd407 auto select from account after to account is selected 2026-09-25 03:19:38 +05:00
flamexode 3b55fa30a8 show currency after searching for account
fixes #40
2026-09-21 21:56:23 +05:00
flamexode 7f0f2707a1 default name to title case of API response name 2026-09-21 18:02:56 +05:00
flamexode 117733b766 change keyboard enter behaviour of add contacts account number field
fixes #43
2026-09-21 17:58:48 +05:00
flamexode d769df6f6b add ability to hide accounts
implements #50
2026-09-21 17:47:26 +05:00
flamexode caf0db60da autocomplete with saved contacts in transfer page
implements #49
2026-09-21 17:47:08 +05:00
flamexode b48022d249 add ability to reorder logins
fixes #47
2026-09-21 17:14:06 +05:00
shihaam 7574a5e8b0 release v1.0.25 - idk numbers
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m33s
2026-09-21 16:30:19 +05:00
shihaam 992c8d4364 release v1.0.24
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m19s
2026-09-21 15:26:40 +05:00
shihaam 80fd238195 fix BML POS QRs 2026-09-21 15:26:18 +05:00
91 changed files with 2264 additions and 338 deletions
+14 -17
View File
@@ -27,24 +27,21 @@ jobs:
- name: Rename APK - name: Rename APK
run: | run: |
APP_NAME="${{ gitea.repository }}" APP_NAME="${{ gitea.repository }}"
APP_NAME="${APP_NAME##*/}" APP_NAME="${APP_NAME//\//-}"
TAG="${{ gitea.ref_name }}" TAG="${{ gitea.ref_name }}"
find .build/release/release/ -maxdepth 1 -name "*.apk" | head -1 | xargs -I{} mv {} ".build/release/release/${APP_NAME}-${TAG}.apk" find .build/release/release/ -maxdepth 1 -name "*.apk" | head -1 | xargs -I{} mv {} ".build/release/release/${APP_NAME}-${TAG}.apk"
- name: Extract release notes - name: Extract release notes
run: | run: |
echo "No release notes" > release_notes.md VERSION_CODE=$(grep 'versionCode = ' app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
# VERSION="${{ gitea.ref_name }}" CHANGELOG="fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt"
# VERSION="${VERSION#v}"
# if [ -f "$CHANGELOG" ]; then
# awk -v ver="$VERSION" ' cp "$CHANGELOG" release_notes.md
# BEGIN { found=0 } else
# /^## \[/ { echo "No changelog found at $CHANGELOG"
# if (found) exit echo "No release notes" > release_notes.md
# if ($0 ~ "\\[" ver "\\]") { found=1; next } fi
# }
# found { print }
# ' CHANGELOG.md > release_notes.md
- name: Create Gitea Release - name: Create Gitea Release
env: env:
@@ -53,7 +50,7 @@ jobs:
GITEA_TOKEN: ${{ secrets.PAT_GITEA }} GITEA_TOKEN: ${{ secrets.PAT_GITEA }}
run: | run: |
APP_NAME="${{ gitea.repository }}" APP_NAME="${{ gitea.repository }}"
APP_NAME="${APP_NAME##*/}" APP_NAME="${APP_NAME//\//-}"
TAG="${{ gitea.ref_name }}" TAG="${{ gitea.ref_name }}"
TITLE="${APP_NAME} ${TAG}" TITLE="${APP_NAME} ${TAG}"
NOTES_FILE="release_notes.md" NOTES_FILE="release_notes.md"
@@ -101,12 +98,12 @@ jobs:
fi fi
APP_NAME="${{ gitea.repository }}" APP_NAME="${{ gitea.repository }}"
APP_NAME="${APP_NAME##*/}" APP_NAME="${APP_NAME//\//-}"
TAG="${{ gitea.ref_name }}" TAG="${{ gitea.ref_name }}"
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk" ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
CAPTION="${APP_NAME} ${TAG}" CAPTION="$(printf '%s\n\n%s' "${APP_NAME} ${TAG}" "$(cat release_notes.md)" | head -c 1024)"
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \ curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
-F "chat_id=${TG_CHAT_ID}" \ -F "chat_id=${TG_CHAT_ID}" \
-F "document=@${ASSET_PATH}" \ -F "document=@${ASSET_PATH}" \
-F "caption=${CAPTION}" --form-string "caption=${CAPTION}"
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 24 versionCode = 28
versionName = "1.0.23" versionName = "1.0.27"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -14,9 +14,15 @@ internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.build() .build()
/**
* Headers are sent in the order (and with the names) BML's own app uses. `accept` is not optional:
* handled errors come back as JSON either way, but when a route throws (HTTP 500) the server
* renders the Internet Banking HTML page — under HTTP 200 — unless the request asks for JSON.
*/
internal fun bmlApiRequest(session: BmlSession, url: String): Request = internal fun bmlApiRequest(session: BmlSession, url: String): Request =
Request.Builder().url(url) Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}") .header("accept", "application/json")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION) .header("x-app-version", BML_APP_VERSION)
.header("user-agent", BML_USER_AGENT)
.header("authorization", "Bearer ${session.accessToken}")
.build() .build()
@@ -74,6 +74,13 @@ data class BmlQrPayInfo(
val currency: String val currency: String
) )
/**
* The pay-request lookup answered `success: false` — [message] is BML's own wording, safe to show
* (e.g. code 103 "The payment request has expired", 112 "Unsupported payment link"). Network and
* parse failures stay plain exceptions so the generic message is used for those instead.
*/
class BmlQrPayLookupException(val code: Int, message: String) : Exception(message)
data class BmlQrPayResult( data class BmlQrPayResult(
val success: Boolean, val success: Boolean,
val merchant: String = "", val merchant: String = "",
@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.bml package sh.sar.basedbank.api.bml
import android.util.Base64
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
@@ -10,17 +11,27 @@ class BmlQrPayClient {
private val client = newBmlApiClient() private val client = newBmlApiClient()
/** /**
* Resolves a BML QR URL to merchant details. * Resolves a BML QR to merchant details. [payTarget] is the QR URL, or for POS QRs the bare
* [base64Url] is the full QR URL Base64-encoded (standard, with padding). * `35` → `20` → `01` reference.
*
* The key is Base64-encoded here without padding, as BML's own app sends it. The padded form
* resolves too, so this is parity rather than a requirement.
*/ */
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo { fun lookupPayRequest(session: BmlSession, payTarget: String): BmlQrPayInfo {
val key = Base64.encodeToString(
payTarget.toByteArray(Charsets.UTF_8), Base64.NO_WRAP or Base64.NO_PADDING)
val request = bmlApiRequest(session, val request = bmlApiRequest(session,
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url") "$BML_BASE_URL/api/mobile/walletpayments/payrequest/$key")
return client.newCall(request).execute().use { response -> return client.newCall(request).execute().use { response ->
val body = response.body?.string() ?: throw Exception("No response") val body = response.body?.string() ?: throw Exception("No response")
// A server-side error renders an HTML page under HTTP 200 rather than an error JSON.
if (!body.trimStart().startsWith("{"))
throw Exception("Unexpected non-JSON response (HTTP ${response.code})")
val json = JSONObject(body) val json = JSONObject(body)
if (!json.optBoolean("success")) if (!json.optBoolean("success"))
throw Exception(json.optString("message").ifBlank { "Lookup failed" }) throw BmlQrPayLookupException(
json.optInt("code"),
json.optString("message").ifBlank { "Lookup failed" })
val payload = json.getJSONObject("payload") val payload = json.getJSONObject("payload")
val addr2 = payload.optString("narrative2").trim() val addr2 = payload.optString("narrative2").trim()
val addr3 = payload.optString("narrative3").trim() val addr3 = payload.optString("narrative3").trim()
@@ -234,6 +234,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private fun setupAccountSearch() { private fun setupAccountSearch() {
binding.tilAccount.setEndIconOnClickListener { performLookup() } binding.tilAccount.setEndIconOnClickListener { performLookup() }
binding.etAccount.setOnEditorActionListener { _, actionId, _ ->
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH) {
performLookup()
true
} else false
}
} }
private fun performLookup() { private fun performLookup() {
@@ -387,7 +393,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
// Auto-fill alias with existing alias or name // Auto-fill alias with existing alias or name
if (binding.etAlias.text.isNullOrBlank()) { if (binding.etAlias.text.isNullOrBlank()) {
binding.etAlias.setText(validation.name) binding.etAlias.setText(sh.sar.basedbank.util.bmlapi.BmlDashboardParser.toTitleCase(validation.name))
} }
binding.etCurrency.setText(validation.currency) binding.etCurrency.setText(validation.currency)
@@ -5,7 +5,6 @@ import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.os.Bundle import android.os.Bundle
import android.util.Base64
import android.view.Gravity import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@@ -128,7 +127,6 @@ class BmlQrPayFragment : Fragment() {
} }
private fun lookupMerchant(qrUrl: String) { private fun lookupMerchant(qrUrl: String) {
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val session = app.anyBmlSession() ?: run { val session = app.anyBmlSession() ?: run {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
@@ -141,7 +139,7 @@ class BmlQrPayFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val info = withContext(Dispatchers.IO) { val info = withContext(Dispatchers.IO) {
try { BmlQrPayClient().lookupPayRequest(session, base64Url) } try { BmlQrPayClient().lookupPayRequest(session, qrUrl) }
catch (_: Exception) { null } catch (_: Exception) { null }
} }
if (_binding == null) return@launch if (_binding == null) return@launch
@@ -0,0 +1,208 @@
package sh.sar.basedbank.ui.home
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.os.bundleOf
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.SheetContactDetailsBinding
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactListParser
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.TransferNetwork
/** Contact details drawer shown when a row in [ContactsFragment] is tapped. */
class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
private var _binding: SheetContactDetailsBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private val contactsFragment get() = parentFragment as? ContactsFragment
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = SheetContactDetailsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val args = requireArguments()
val contactId = args.getString(ARG_CONTACT_ID)
val profileId = args.getString(ARG_PROFILE_ID)
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
val contact = contacts.firstOrNull { it.benefNo == contactId && it.profileId == profileId }
?.let { ContactListParser.from(it) }
if (contact == null) dismissAllowingStateLoss() else bind(contact)
}
}
private fun bind(contact: ContactDisplay) {
val sizePx = resources.getDimensionPixelSize(android.R.dimen.app_icon_size).coerceAtLeast(96) * 2
val photo = contact.imageHash?.let { ContactImageCache.load(requireContext(), it) }
binding.ivPhoto.setImageBitmap(photo ?: contactInitialsBitmap(contact.name, contact.bankColor, sizePx))
binding.tvName.text = contact.name
binding.actionTransfer.visibility = if (contact.canTransfer) View.VISIBLE else View.GONE
// QR stays visible for every contact; greyed out where PayMV QR isn't supported yet
val canQr = canShowPayMvQr(contact)
binding.btnQr.isEnabled = canQr
binding.tvQrLabel.alpha = if (canQr) 1f else 0.38f
binding.actionEdit.visibility = if (contact.canEdit) View.VISIBLE else View.GONE
binding.actionDelete.visibility = if (contact.canDelete) View.VISIBLE else View.GONE
binding.btnTransfer.setOnClickListener {
val parent = contactsFragment
dismiss()
parent?.openTransfer(contact)
}
binding.btnQr.setOnClickListener {
val activity = requireActivity() as HomeActivity
val bank = contactBank(contact) ?: return@setOnClickListener
val qrName = contact.realName.ifBlank { contact.name }
dismiss()
activity.showWithBackStack(PayMvQrFragment.forContact(contact.accountNumber, qrName, bank))
}
binding.btnEdit.setOnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
binding.btnDelete.setOnClickListener {
val parent = contactsFragment
dismiss()
parent?.confirmDelete(contact)
}
binding.tvAccount.text = contact.accountNumber
val copy = View.OnClickListener { copyAccount(contact.accountNumber) }
binding.rowAccount.setOnClickListener(copy)
binding.btnCopyAccount.setOnClickListener(copy)
binding.btnShareAccount.setOnClickListener {
shareAccount(contact.realName.ifBlank { contact.name }, contact.accountNumber)
}
val showRealName = contact.realName.isNotBlank() && contact.realName != contact.name
binding.tvRealName.text = contact.realName
binding.rowRealName.visibility = if (showRealName) View.VISIBLE else View.GONE
binding.tvCurrency.text = contact.currency ?: ""
binding.rowCurrency.visibility = if (contact.currency != null) View.VISIBLE else View.GONE
binding.tvBank.text = contact.bankName ?: ""
binding.rowBank.visibility = if (contact.bankName != null) View.VISIBLE else View.GONE
val bankLogo = bankLogoRes(contact)
if (bankLogo != null) {
binding.ivBankIcon.setImageResource(bankLogo)
binding.ivBankIcon.imageTintList = null
}
binding.sourceSection.visibility = View.GONE
viewLifecycleOwner.lifecycleScope.launch {
val appContext = requireContext().applicationContext
val source = withContext(Dispatchers.IO) { sourceProfile(appContext, contact) }
val b = _binding ?: return@launch
if (source == null) return@launch
b.ivSourceIcon.setImageResource(source.logoRes)
b.tvSource.text = source.name
b.tvSourceBank.setText(source.bankNameRes)
b.sourceSection.visibility = View.VISIBLE
}
}
/** Which bank the contact's account is at ("BML" / "MIB"); null when we can't tell. */
private fun contactBank(contact: ContactDisplay): String? = when {
contact.network == TransferNetwork.MIB -> "MIB"
contact.accountNumber.matches(Regex("^7\\d{12}$")) -> "BML"
contact.accountNumber.matches(Regex("^9\\d{16}$")) -> "MIB"
else -> null
}
/** Logo of the contact's bank; null when we have no logo for it (only MIB and BML for now). */
private fun bankLogoRes(contact: ContactDisplay): Int? = when (contactBank(contact)) {
"BML" -> R.drawable.bml_logo_vector
"MIB" -> R.drawable.mib_logo
else -> null
}
/**
* PayMV QR is only offered for contacts whose account is at BML for now.
* TODO: enable for MIB and other banks once their PayMV QR payloads are verified —
* PayMvQrFragment.forContact already takes the bank, so this check is the only gate.
*/
private fun canShowPayMvQr(contact: ContactDisplay): Boolean =
contactBank(contact) == "BML" &&
contact.currency?.contains("USD", ignoreCase = true) != true // PayMV QR is MVR only
private data class SourceProfile(val logoRes: Int, val bankNameRes: Int, val name: String)
/** Which of the user's own bank profiles this contact is saved under. */
private fun sourceProfile(context: Context, contact: ContactDisplay): SourceProfile? {
val store = CredentialStore(context)
return when (contact.network) {
TransferNetwork.BML -> {
// BML contacts carry either the loginId or a BML profileId
val loginId = store.getBmlLoginIds().firstOrNull { loginId ->
loginId == contact.profileId ||
store.loadBmlProfiles(loginId).any { it.profileId == contact.profileId }
} ?: return null
val profile = store.loadBmlProfiles(loginId).firstOrNull { it.profileId == contact.profileId }
val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
val name = (if (profile != null && profile.profileType == "business") profile.name else fullName ?: profile?.name)
?: return null
SourceProfile(R.drawable.bml_logo_vector, R.string.bml_name, name)
}
TransferNetwork.FAHIPAY -> {
val name = store.getFahipayLoginIds().firstNotNullOfOrNull { loginId ->
store.loadFahipayUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
} ?: return null
SourceProfile(R.drawable.fahipay_logo, R.string.fahipay_name, name)
}
else -> {
val profile = store.getMibLoginIds().firstNotNullOfOrNull { loginId ->
store.loadMibProfiles(loginId).firstOrNull { it.profileId == contact.profileId }
} ?: return null
SourceProfile(R.drawable.mib_logo, R.string.mib_name, profile.name)
}
}
}
private fun shareAccount(accountName: String, account: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "$accountName\n$account")
}
startActivity(Intent.createChooser(intent, getString(R.string.contact_share_account)))
}
private fun copyAccount(account: String) {
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
Toast.makeText(requireContext(), R.string.contact_account_copied, Toast.LENGTH_SHORT).show()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
private const val ARG_CONTACT_ID = "contact_id"
private const val ARG_PROFILE_ID = "profile_id"
fun newInstance(contact: ContactDisplay) = ContactDetailsSheetFragment().apply {
arguments = bundleOf(ARG_CONTACT_ID to contact.id, ARG_PROFILE_ID to contact.profileId)
}
}
}
@@ -11,14 +11,13 @@ import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ItemContactBinding import sh.sar.basedbank.databinding.ItemContactBinding
import sh.sar.basedbank.util.ContactDisplay import sh.sar.basedbank.util.ContactDisplay
class ContactsAdapter( class ContactsAdapter(
private val imageCache: MutableMap<String, Bitmap>, private val imageCache: MutableMap<String, Bitmap>,
private val onImageNeeded: (hash: String) -> Unit, private val onImageNeeded: (hash: String) -> Unit,
private val onDeleteClick: (ContactDisplay) -> Unit, private val onContactClick: (ContactDisplay) -> Unit,
private val onTransferClick: (ContactDisplay) -> Unit private val onTransferClick: (ContactDisplay) -> Unit
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() { ) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
@@ -66,12 +65,9 @@ class ContactsAdapter(
val pos = holder.bindingAdapterPosition val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos]) if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos])
} }
binding.btnEditContact.setOnClickListener { binding.root.setOnClickListener {
Toast.makeText(it.context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
binding.btnDeleteContact.setOnClickListener {
val pos = holder.bindingAdapterPosition val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onDeleteClick(displayed[pos]) if (pos != RecyclerView.NO_POSITION) onContactClick(displayed[pos])
} }
binding.root.setOnLongClickListener { binding.root.setOnLongClickListener {
val pos = holder.bindingAdapterPosition val pos = holder.bindingAdapterPosition
@@ -107,37 +103,33 @@ class ContactsAdapter(
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
binding.btnTransferContact.visibility = binding.btnTransferContact.visibility =
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
binding.btnEditContact.visibility =
if (contact.canEdit) android.view.View.VISIBLE else android.view.View.GONE
binding.btnDeleteContact.visibility =
if (contact.canDelete) android.view.View.VISIBLE else android.view.View.GONE
if (photo != null) { if (photo != null) {
binding.ivContactPhoto.setImageBitmap(photo) binding.ivContactPhoto.setImageBitmap(photo)
} else { } else {
val sizePx = binding.ivContactPhoto.context.resources
.getDimensionPixelSize(android.R.dimen.app_icon_size)
.coerceAtLeast(96)
binding.ivContactPhoto.setImageBitmap( binding.ivContactPhoto.setImageBitmap(
makeInitialsBitmap(contact.name, contact.bankColor) contactInitialsBitmap(contact.name, contact.bankColor, sizePx)
) )
} }
} }
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
val sizePx = binding.ivContactPhoto.context.resources
.getDimensionPixelSize(android.R.dimen.app_icon_size)
.coerceAtLeast(96)
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 letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val metrics = paint.fontMetrics
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
return bm
}
} }
} }
internal fun contactInitialsBitmap(name: String, colorHex: String, sizePx: Int): Bitmap {
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 letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val metrics = paint.fontMetrics
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
return bm
}
@@ -2,13 +2,20 @@ package sh.sar.basedbank.ui.home
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.os.Bundle import android.os.Bundle
import android.util.Base64 import android.util.Base64
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
@@ -59,7 +66,7 @@ class ContactsFragment : Fragment() {
ContactsAdapter( ContactsAdapter(
imageCache = sharedImageCache, imageCache = sharedImageCache,
onImageNeeded = { hash -> fetchImage(hash) }, onImageNeeded = { hash -> fetchImage(hash) },
onDeleteClick = { contact -> confirmDelete(contact) }, onContactClick = { contact -> showDetails(contact) },
onTransferClick = { contact -> openTransfer(contact) } onTransferClick = { contact -> openTransfer(contact) }
).also { a -> ).also { a ->
a.setFilter(page.categoryId, currentSearch) a.setFilter(page.categoryId, currentSearch)
@@ -170,7 +177,12 @@ class ContactsFragment : Fragment() {
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false) binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
} }
private fun openTransfer(contact: ContactDisplay) { private fun showDetails(contact: ContactDisplay) {
if (childFragmentManager.findFragmentByTag("contact_details") != null) return
ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details")
}
internal fun openTransfer(contact: ContactDisplay) {
val fragment = TransferFragment.newInstance( val fragment = TransferFragment.newInstance(
accountNumber = contact.accountNumber, accountNumber = contact.accountNumber,
displayName = contact.name, displayName = contact.name,
@@ -181,10 +193,26 @@ class ContactsFragment : Fragment() {
(requireActivity() as HomeActivity).showWithBackStack(fragment) (requireActivity() as HomeActivity).showWithBackStack(fragment)
} }
private fun confirmDelete(contact: ContactDisplay) { internal fun confirmDelete(contact: ContactDisplay) {
MaterialAlertDialogBuilder(requireContext()) val ctx = requireContext()
val errorColor = MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorError)
val icon = ContextCompat.getDrawable(ctx, R.drawable.ic_delete)?.mutate()?.apply { setTint(errorColor) }
val prompt = getString(R.string.contact_delete_message, contact.name)
val message = SpannableStringBuilder(prompt).apply {
val start = prompt.indexOf(contact.name)
if (start >= 0) setSpan(StyleSpan(Typeface.BOLD), start, start + contact.name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
append("\n").append(contact.accountNumber)
append("\n\n")
val warnStart = length
append(getString(R.string.contact_delete_warning))
setSpan(ForegroundColorSpan(errorColor), warnStart, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
MaterialAlertDialogBuilder(ctx, R.style.ThemeOverlay_BasedBank_DestructiveDialog)
.setIcon(icon)
.setTitle(R.string.contact_delete_title) .setTitle(R.string.contact_delete_title)
.setMessage(getString(R.string.contact_delete_message, contact.name)) .setMessage(message)
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) } .setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.show() .show()
@@ -43,10 +43,10 @@ class DashboardFragment : Fragment() {
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null } val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw) val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { if (bmlTarget != null) {
(requireActivity() as HomeActivity).navigateTo( (requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber) R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
) )
} else { } else {
val qr = PaymvQrParser.parse(raw) val qr = PaymvQrParser.parse(raw)
@@ -121,7 +121,8 @@ class DashboardFragment : Fragment() {
val bmlItems = (viewModel.accounts.value ?: emptyList()) val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) } .filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
.map { CardItem.Bml(it) } .map { CardItem.Bml(it) }
val all = bmlItems + mibItems val rank = credStore.loginRank()
val all = (bmlItems + mibItems).sortedBy { rank(it.loginTag) }
val defaultNum = credStore.getDefaultCardAccountNumber() val defaultNum = credStore.getDefaultCardAccountNumber()
val ordered = if (defaultNum != null) { val ordered = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum } val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
@@ -44,9 +44,11 @@ class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
notifyDataSetChanged() notifyDataSetChanged()
} }
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) { fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>, mibFirst: Boolean = true) {
expandedPositions.clear() expandedPositions.clear()
items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) } val mibItems = mibDeals.map { Item.Mib(it) }
val bmlItems = bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
items = if (mibFirst) mibItems + bmlItems else bmlItems + mibItems
notifyDataSetChanged() notifyDataSetChanged()
} }
@@ -14,6 +14,7 @@ import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentFinancingBinding import sh.sar.basedbank.databinding.FragmentFinancingBinding
import sh.sar.basedbank.util.CredentialStore
class FinancingFragment : Fragment() { class FinancingFragment : Fragment() {
@@ -67,7 +68,11 @@ class FinancingFragment : Fragment() {
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> = val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] } loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
adapter.update(latestMibDeals, bmlLoans) // MIB deals carry no login tag, so order the MIB and BML blocks by each bank's first login
val order = CredentialStore(requireContext()).getLoginOrder()
val firstMib = order.indexOfFirst { it.startsWith("mib_") }
val firstBml = order.indexOfFirst { it.startsWith("bml_") }
adapter.update(latestMibDeals, bmlLoans, mibFirst = firstBml < 0 || (firstMib in 0 until firstBml))
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty() val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
@@ -244,9 +244,9 @@ class HomeActivity : AppCompatActivity() {
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds()) val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds()) val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds())
val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa
if (merged.isNotEmpty()) viewModel.accounts.value = merged if (merged.isNotEmpty()) viewModel.accounts.value = merged.filterVisibleAccounts()
val cachedCards = CardsCache.load(this) val cachedCards = CardsCache.load(this)
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards.filterVisibleCards()
val cachedFinancing = FinancingCache.load(this) val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val cachedBmlLoans = FinancingCache.loadBmlLoans(this) val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
@@ -528,9 +528,9 @@ fun applyNavLabelVisibility() {
private fun routeSharedQrText(text: String) { private fun routeSharedQrText(text: String) {
val store = CredentialStore(this) val store = CredentialStore(this)
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text) val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { if (bmlTarget != null) {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber())) navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
return return
} }
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text) val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
@@ -774,47 +774,13 @@ fun applyNavLabelVisibility() {
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
val bmlClient = BmlAccountClient() // Hidden profiles are never fetched; unhiding one fetches it on its own (fetchEnabledBmlProfiles)
val hiddenProfiles = store.getHiddenBmlProfileIds(loginId)
for (profile in savedProfiles) { for (profile in savedProfiles) {
val saved = store.loadBmlProfileSession(profile.profileId) if (profile.profileId in hiddenProfiles) continue
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
if (saved == null) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
continue
}
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
suspend fun fetchWithSession(session: BmlSession) {
bmlClient.checkProfile(session)
val accounts = bmlClient.fetchAccounts(session, loginTag, profile.name, profile.profileId)
app.bmlSessions[profile.profileId] = session
allAccounts += accounts
}
suspend fun tryRefresh() {
if (refreshToken == null) throw Exception("No refresh token")
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
if (newSession.refreshToken.isNotBlank())
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
if (newSession.expiresAt > 0)
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
fetchWithSession(newSession)
}
try { try {
if (tokenKnownExpired) { allAccounts += fetchBmlProfileAccounts(store, loginId, profile)
tryRefresh() ?: AccountCache.loadBml(this@HomeActivity, loginId).filter { it.profileId == profile.profileId }
} else {
try {
fetchWithSession(BmlSession(saved.first, saved.second))
} catch (_: AuthExpiredException) {
tryRefresh()
}
}
} catch (e: java.io.IOException) { } catch (e: java.io.IOException) {
refreshErrors.add("NO_INTERNET") refreshErrors.add("NO_INTERNET")
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId) allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
@@ -985,15 +951,17 @@ fun applyNavLabelVisibility() {
refreshBmlLoanDetails() refreshBmlLoanDetails()
for ((loginId, session) in app.mibSessions) { for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList() val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshMibCards(loginId, session, profiles) refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
} }
} }
} }
/** Filters accounts whose profileId the user has hidden in settings. */ /** Filters accounts whose profileId or account number the user has hidden in settings. */
private fun List<BankAccount>.filterVisibleAccounts(): List<BankAccount> { private fun List<BankAccount>.filterVisibleAccounts(): List<BankAccount> {
val store = CredentialStore(this@HomeActivity) val store = CredentialStore(this@HomeActivity)
val hiddenAccountNumbers = store.getHiddenAccountNumbers()
return filter { acc -> return filter { acc ->
if (acc.accountNumber in hiddenAccountNumbers) return@filter false
when (acc.bank) { when (acc.bank) {
"MIB" -> { "MIB" -> {
val loginId = acc.loginTag.removePrefix("mib_") val loginId = acc.loginTag.removePrefix("mib_")
@@ -1022,10 +990,140 @@ fun applyNavLabelVisibility() {
return filter { it.profileId !in hidden } return filter { it.profileId !in hidden }
} }
/** Called by SettingsLoginsFragment after the user changes profile visibility. */ /** Drops MIB cards belonging to profiles the user has hidden. */
fun applyProfileVisibility() { private fun List<sh.sar.basedbank.api.mib.MibCard>.filterVisibleCards(): List<sh.sar.basedbank.api.mib.MibCard> {
val current = viewModel.accounts.value ?: return val store = CredentialStore(this@HomeActivity)
viewModel.accounts.value = current.filterVisibleAccounts() return filter { card ->
card.profileId.isEmpty() || card.profileId !in store.getHiddenMibProfileIds(card.loginTag.removePrefix("mib_"))
}
}
/**
* Called by SettingsLoginsFragment after the user hides/unhides profiles or accounts.
* Re-filters the in-memory data only — no network. Newly unhidden profiles are fetched
* separately via [fetchEnabledMibProfiles] / [fetchEnabledBmlProfiles].
*/
fun applyVisibility() {
val app = application as BasedBankApp
viewModel.accounts.value = (app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + app.mfaisaAccounts).filterVisibleAccounts()
viewModel.mibCards.value?.let { cards ->
val visible = cards.filterVisibleCards()
viewModel.mibCards.value = visible
CardsCache.save(this, visible)
}
}
/** Fetches accounts, cards and financing for MIB profiles the user just unhid (hidden ones are skipped on refresh). */
fun fetchEnabledMibProfiles(loginId: String, profileIds: Set<String>) {
if (profileIds.isEmpty()) return
val app = application as BasedBankApp
val loginTag = "mib_$loginId"
val enabled = (app.mibProfilesMap[loginId] ?: emptyList()).filter { it.profileId in profileIds }
binding.refreshIndicator.visibility = View.VISIBLE
lifecycleScope.launch {
val fresh = withContext(Dispatchers.IO) {
val session = app.mibSessions[loginId]
if (session != null && enabled.isNotEmpty()) {
try {
val accounts = app.mibFlowFor(loginId).fetchAllProfiles(session, enabled, loginTag)
if (accounts.isNotEmpty()) {
return@withContext app.mibAccounts.filter { it.loginTag != loginTag || it.profileId !in profileIds } + accounts
}
} catch (_: Exception) { }
}
// Session expired — log this one login in again (it now includes the unhidden profiles)
val store = CredentialStore(this@HomeActivity)
val creds = store.loadMibCredentials(loginId) ?: return@withContext null
try {
val flow = MibLoginFlow(store)
val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
app.mibSessions[loginId] = flow.lastSession!!
app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow
store.saveMibProfiles(loginId, flow.lastProfiles)
app.mibAccounts.filter { it.loginTag != loginTag } + accounts
} catch (_: Exception) { null }
}
binding.refreshIndicator.visibility = View.GONE
if (fresh == null) return@launch
app.mibAccounts = fresh
AccountCache.save(this@HomeActivity, fresh)
applyVisibility()
val session = app.mibSessions[loginId] ?: return@launch
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshMibCards(loginId, session, profiles.filter { it.profileId in profileIds })
// Financing deals carry no profile id, so the login's visible profiles are fetched together
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
}
/** Fetches accounts (and loan details / limits) for BML profiles the user just unhid. */
fun fetchEnabledBmlProfiles(loginId: String, profileIds: Set<String>) {
if (profileIds.isEmpty()) return
val app = application as BasedBankApp
val store = CredentialStore(this)
val enabled = store.loadBmlProfiles(loginId).filter { it.profileId in profileIds }
if (enabled.isEmpty()) return
binding.refreshIndicator.visibility = View.VISIBLE
lifecycleScope.launch {
val fetched = withContext(Dispatchers.IO) {
enabled.mapNotNull { profile ->
try { fetchBmlProfileAccounts(store, loginId, profile)?.let { profile.profileId to it } } catch (_: Exception) { null }
}
}
binding.refreshIndicator.visibility = View.GONE
if (fetched.isEmpty()) return@launch
val fetchedIds = fetched.map { it.first }.toSet()
val loginTag = "bml_$loginId"
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != loginTag || it.profileId !in fetchedIds } +
fetched.flatMap { it.second }
AccountCache.saveBml(this@HomeActivity, loginId, app.bmlAccounts.filter { it.loginTag == loginTag })
applyVisibility()
fetchedIds.forEach { id -> app.bmlSessions[id]?.let { refreshBmlLimits(it) } }
refreshBmlLoanDetails()
}
}
/**
* Fetches one BML profile's accounts with its saved session, refreshing the token when expired.
* Returns null when the profile has no saved session; throws on network/server errors.
*/
private fun fetchBmlProfileAccounts(store: CredentialStore, loginId: String, profile: BmlProfile): List<BankAccount>? {
val app = application as BasedBankApp
val saved = store.loadBmlProfileSession(profile.profileId) ?: return null
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
val bmlClient = BmlAccountClient()
fun fetchWithSession(session: BmlSession): List<BankAccount> {
bmlClient.checkProfile(session)
val accounts = bmlClient.fetchAccounts(session, "bml_$loginId", profile.name, profile.profileId)
app.bmlSessions[profile.profileId] = session
return accounts
}
fun tryRefresh(): List<BankAccount> {
if (refreshToken == null) throw Exception("No refresh token")
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
if (newSession.refreshToken.isNotBlank())
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
if (newSession.expiresAt > 0)
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
return fetchWithSession(newSession)
}
return if (tokenKnownExpired) {
tryRefresh()
} else {
try {
fetchWithSession(BmlSession(saved.first, saved.second))
} catch (_: AuthExpiredException) {
tryRefresh()
}
}
} }
private fun refreshBmlLimits(session: BmlSession) { private fun refreshBmlLimits(session: BmlSession) {
@@ -1259,7 +1357,7 @@ fun applyNavLabelVisibility() {
val app = application as BasedBankApp val app = application as BasedBankApp
for ((loginId, session) in app.mibSessions) { for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList() val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshMibCards(loginId, session, profiles) refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
} }
} }
@@ -1284,7 +1382,8 @@ fun applyNavLabelVisibility() {
} }
if (cards.isNotEmpty()) { if (cards.isNotEmpty()) {
val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf() val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf()
existing.removeAll { it.loginTag == "mib_$loginId" } val fetchedIds = profiles.map { it.profileId }.toSet()
existing.removeAll { it.loginTag == "mib_$loginId" && (it.profileId in fetchedIds || it.profileId.isEmpty()) }
existing += cards existing += cards
viewModel.mibCards.postValue(existing) viewModel.mibCards.postValue(existing)
CardsCache.save(this@HomeActivity, existing) CardsCache.save(this@HomeActivity, existing)
@@ -1,7 +1,8 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import sh.sar.basedbank.api.bml.BmlForeignLimit import sh.sar.basedbank.api.bml.BmlForeignLimit
import sh.sar.basedbank.api.bml.BmlLoanDetail import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
@@ -9,14 +10,21 @@ import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibCard import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.util.CredentialStore
sealed class CardItem { sealed class CardItem {
data class Mib(val card: MibCard) : CardItem() abstract val loginTag: String
data class Bml(val account: BankAccount) : CardItem() data class Mib(val card: MibCard) : CardItem() { override val loginTag get() = card.loginTag }
data class Bml(val account: BankAccount) : CardItem() { override val loginTag get() = account.loginTag }
} }
class HomeViewModel : ViewModel() { class HomeViewModel(application: Application) : AndroidViewModel(application) {
val accounts = MutableLiveData<List<BankAccount>>(emptyList()) /** Always kept in the user's login order (Settings > Logins), so every screen lists accounts the same way. */
val accounts: MutableLiveData<List<BankAccount>> = object : MutableLiveData<List<BankAccount>>(emptyList()) {
override fun setValue(value: List<BankAccount>?) {
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
}
}
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList()) val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
/** BML loan details keyed by account internalId. */ /** BML loan details keyed by account internalId. */
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap()) val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
@@ -26,7 +34,19 @@ class HomeViewModel : ViewModel() {
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>) data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList()) val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
val mibCards = MutableLiveData<List<MibCard>?>(null) val mibCards: MutableLiveData<List<MibCard>?> = object : MutableLiveData<List<MibCard>?>(null) {
override fun setValue(value: List<MibCard>?) {
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
}
}
fun loginRank(): (String) -> Int = CredentialStore(getApplication()).loginRank()
/** Re-applies the login order after the user changes it. */
fun resortByLoginOrder() {
accounts.value = accounts.value
mibCards.value = mibCards.value
}
val hideAmounts = MutableLiveData<Boolean>(false) val hideAmounts = MutableLiveData<Boolean>(false)
@@ -18,7 +18,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import com.google.android.material.color.MaterialColors
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountClient import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.mib.MibProfileClient import sh.sar.basedbank.api.mib.MibProfileClient
import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.api.mib.MibLoginFlow
@@ -32,7 +34,7 @@ class OtpFragment : Fragment() {
private var _binding: FragmentOtpBinding? = null private var _binding: FragmentOtpBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private data class OtpEntry(val label: String, val seed: String) private data class OtpEntry(val bank: String, val name: String?, val seed: String)
private inner class OtpAdapter(private val entries: List<OtpEntry>) : private inner class OtpAdapter(private val entries: List<OtpEntry>) :
RecyclerView.Adapter<OtpAdapter.VH>() { RecyclerView.Adapter<OtpAdapter.VH>() {
@@ -45,18 +47,17 @@ class OtpFragment : Fragment() {
VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false)) VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: VH, position: Int) { override fun onBindViewHolder(holder: VH, position: Int) {
holder.b.tvOtpLabel.text = entries[position].label val entry = entries[position]
update(holder.b, entries[position].seed) val b = holder.b
holder.b.root.setOnClickListener { b.tvOtpBank.text = entry.bank
val code = holder.b.tvOtpCode.text.toString().replace(" ", "") b.tvOtpLabel.text = entry.name ?: "Authenticator"
if (code.isNotEmpty()) { b.ivBankLogo.setImageResource(
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager if (entry.bank == "BML") R.drawable.bml_logo_vector else R.drawable.mib_logo
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code)) )
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { update(b, entry.seed)
Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show() b.root.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
} b.btnCopyOtp.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
} b.btnCopyNextOtp.setOnClickListener { copyCode(it.context, b.tvNextOtpCode.text, "Next OTP copied") }
}
} }
fun tick() { fun tick() {
@@ -71,9 +72,32 @@ class OtpFragment : Fragment() {
val secondsInPeriod = (epochSeconds % 30).toInt() val secondsInPeriod = (epochSeconds % 30).toInt()
val remaining = 30 - secondsInPeriod val remaining = 30 - secondsInPeriod
val code = try { Totp.generate(seed) } catch (_: Exception) { "------" } val code = try { Totp.generate(seed) } catch (_: Exception) { "------" }
val next = try { Totp.generate(seed, periodOffset = 1) } catch (_: Exception) { "------" }
b.tvOtpCode.text = "${code.take(3)} ${code.drop(3)}" b.tvOtpCode.text = "${code.take(3)} ${code.drop(3)}"
b.tvNextOtpCode.text = "${next.take(3)} ${next.drop(3)}"
b.otpProgress.progress = remaining b.otpProgress.progress = remaining
b.tvOtpCountdown.text = "Refreshes in $remaining second${if (remaining == 1) "" else "s"}" b.tvOtpCountdown.text = remaining.toString()
// Turn the ring and code red in the last few seconds of the window
val expiring = remaining <= 5
val accent = MaterialColors.getColor(b.root,
if (expiring) com.google.android.material.R.attr.colorError else com.google.android.material.R.attr.colorPrimary)
b.otpProgress.setIndicatorColor(accent)
b.tvOtpCode.setTextColor(accent)
b.tvOtpCountdown.setTextColor(
if (expiring) accent
else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurfaceVariant)
)
}
}
private fun copyCode(context: Context, text: CharSequence, message: String) {
val code = text.toString().replace(" ", "")
if (code.isEmpty() || code.contains('-')) return
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
} }
} }
@@ -86,21 +110,24 @@ class OtpFragment : Fragment() {
val store = CredentialStore(requireContext()) val store = CredentialStore(requireContext())
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val entries = mutableListOf<OtpEntry>() val rank = store.loginRank()
val tagged = mutableListOf<Pair<String, OtpEntry>>()
for (loginId in store.getMibLoginIds()) { for (loginId in store.getMibLoginIds()) {
val creds = store.loadMibCredentials(loginId) ?: continue val creds = store.loadMibCredentials(loginId) ?: continue
val name = store.loadMibFullName(loginId) val name = store.loadMibFullName(loginId)
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed)) tagged.add(CredentialStore.loginKey("mib", loginId) to OtpEntry("MIB", name, creds.otpSeed))
} }
for (loginId in store.getBmlLoginIds()) { for (loginId in store.getBmlLoginIds()) {
val creds = store.loadBmlCredentials(loginId) ?: continue val creds = store.loadBmlCredentials(loginId) ?: continue
val name = store.loadBmlUserProfile(loginId)?.fullName val name = store.loadBmlUserProfile(loginId)?.fullName
entries.add(OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed)) tagged.add(CredentialStore.loginKey("bml", loginId) to OtpEntry("BML", name?.takeIf { it.isNotBlank() }, creds.otpSeed))
} }
val entries = tagged.sortedBy { rank(it.first) }.map { it.second }.toMutableList()
val adapter = OtpAdapter(entries) val adapter = OtpAdapter(entries)
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
binding.emptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
// Fetch real names in background if not yet cached, then refresh labels // Fetch real names in background if not yet cached, then refresh labels
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
@@ -122,7 +149,7 @@ class OtpFragment : Fragment() {
)) ))
val seed = store.loadMibCredentials(loginId)?.otpSeed val seed = store.loadMibCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed } val idx = entries.indexOfFirst { it.seed == seed }
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true } if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
} }
} }
} }
@@ -143,7 +170,7 @@ class OtpFragment : Fragment() {
)) ))
val seed = store.loadBmlCredentials(loginId)?.otpSeed val seed = store.loadBmlCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed } val idx = entries.indexOfFirst { it.seed == seed }
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "BML · ${info.fullName}"); changed = true } if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true }
} }
} }
} }
@@ -48,6 +48,8 @@ class PayMvQrFragment : Fragment() {
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private var selectedAccount: BankAccount? = null private var selectedAccount: BankAccount? = null
/** Set when opened for a saved contact: the QR is for their account, not one of ours. */
private var contactTarget: QrTarget? = null
private var generatedBitmap: Bitmap? = null private var generatedBitmap: Bitmap? = null
private var generateJob: Job? = null private var generateJob: Job? = null
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>() private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
@@ -59,7 +61,17 @@ class PayMvQrFragment : Fragment() {
return binding.root return binding.root
} }
/** Who the QR pays into — one of our own accounts, or a contact's. */
private data class QrTarget(val accountNumber: String, val name: String, val bank: String)
private fun currentTarget(): QrTarget? = contactTarget
?: selectedAccount?.let { QrTarget(it.accountNumber, it.accountBriefName, it.bank) }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
contactTarget = arguments?.let { args ->
val number = args.getString(ARG_ACCOUNT_NUMBER) ?: return@let null
QrTarget(number, args.getString(ARG_ACCOUNT_NAME).orEmpty(), args.getString(ARG_BANK).orEmpty())
}
val basePaddingBottom = view.paddingBottom val basePaddingBottom = view.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation) val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
@@ -68,7 +80,15 @@ class PayMvQrFragment : Fragment() {
v.updatePadding(bottom = basePaddingBottom + navBarBottom) v.updatePadding(bottom = basePaddingBottom + navBarBottom)
insets insets
} }
setupDropdown() if (contactTarget != null) {
// Not our account, so no account picker and our phone number doesn't belong on it
binding.tilAccount.visibility = View.GONE
binding.layoutIncludePhone.visibility = View.GONE
binding.switchIncludePhone.isChecked = false
scheduleGenerate()
} else {
setupDropdown()
}
binding.etAmount.addTextChangedListener { scheduleGenerate() } binding.etAmount.addTextChangedListener { scheduleGenerate() }
binding.etReference.addTextChangedListener { scheduleGenerate() } binding.etReference.addTextChangedListener { scheduleGenerate() }
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() } binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
@@ -119,8 +139,8 @@ class PayMvQrFragment : Fragment() {
} }
private suspend fun generateQr() { private suspend fun generateQr() {
val account = selectedAccount ?: return val target = currentTarget() ?: return
val acquirer = when (account.bank) { val acquirer = when (target.bank) {
"BML" -> "MALBMVMV" "BML" -> "MALBMVMV"
"MIB" -> "MADVMVMV" "MIB" -> "MADVMVMV"
"FAHIPAY" -> "FAHIMVMV" "FAHIPAY" -> "FAHIMVMV"
@@ -133,10 +153,10 @@ class PayMvQrFragment : Fragment() {
?.let { "%.2f".format(it) } ?.let { "%.2f".format(it) }
val ctx = requireContext() val ctx = requireContext()
val includePhone = binding.switchIncludePhone.isChecked val account = selectedAccount
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag) val mobile = if (binding.switchIncludePhone.isChecked && contactTarget == null && account != null) {
val store = CredentialStore(ctx) val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
val mobile = if (includePhone) { val store = CredentialStore(ctx)
when (account.bank) { when (account.bank) {
"BML" -> store.loadBmlUserProfile(loginId)?.mobile "BML" -> store.loadBmlUserProfile(loginId)?.mobile
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile "FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
@@ -154,8 +174,8 @@ class PayMvQrFragment : Fragment() {
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default) ?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
val bmp = withContext(Dispatchers.Default) { val bmp = withContext(Dispatchers.Default) {
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted, mobile, purpose) val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose)
renderQrCard(ctx, account, payload, amountFormatted) renderQrCard(ctx, target, payload, amountFormatted)
} }
if (_binding == null) return if (_binding == null) return
generatedBitmap = bmp generatedBitmap = bmp
@@ -224,7 +244,7 @@ class PayMvQrFragment : Fragment() {
private fun renderQrCard( private fun renderQrCard(
ctx: Context, ctx: Context,
account: BankAccount, target: QrTarget,
qrPayload: String, qrPayload: String,
amountStr: String? amountStr: String?
): Bitmap { ): Bitmap {
@@ -246,7 +266,7 @@ class PayMvQrFragment : Fragment() {
canvas.drawColor(Color.WHITE) canvas.drawColor(Color.WHITE)
// --- Bank logo top-left --- // --- Bank logo top-left ---
val logoRes = when (account.bank) { val logoRes = when (target.bank) {
"BML" -> R.drawable.bml_logo_vector "BML" -> R.drawable.bml_logo_vector
"MIB" -> R.drawable.mib_faisanet_logo "MIB" -> R.drawable.mib_faisanet_logo
else -> R.drawable.fahipay_logo_long else -> R.drawable.fahipay_logo_long
@@ -279,7 +299,7 @@ class PayMvQrFragment : Fragment() {
paint.color = Color.WHITE paint.color = Color.WHITE
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textAlign = Paint.Align.CENTER paint.textAlign = Paint.Align.CENTER
val nameText = account.accountBriefName.uppercase() val nameText = target.name.uppercase()
paint.textSize = 36f paint.textSize = 36f
val maxNameW = boxR - boxL - 48f val maxNameW = boxR - boxL - 48f
if (paint.measureText(nameText) > maxNameW) { if (paint.measureText(nameText) > maxNameW) {
@@ -338,13 +358,13 @@ class PayMvQrFragment : Fragment() {
private fun shareQr() { private fun shareQr() {
val bmp = generatedBitmap ?: return val bmp = generatedBitmap ?: return
val account = selectedAccount ?: return val target = currentTarget() ?: return
lifecycleScope.launch { lifecycleScope.launch {
val uri = withContext(Dispatchers.IO) { val uri = withContext(Dispatchers.IO) {
try { try {
val dir = File(requireContext().cacheDir, "qr") val dir = File(requireContext().cacheDir, "qr")
dir.mkdirs() dir.mkdirs()
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_") val safeName = target.name.replace(Regex("[^A-Za-z0-9_]"), "_")
val file = File(dir, "${safeName}_paymv_qr.png") val file = File(dir, "${safeName}_paymv_qr.png")
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) } FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
FileProvider.getUriForFile( FileProvider.getUriForFile(
@@ -366,11 +386,11 @@ class PayMvQrFragment : Fragment() {
private fun saveQr() { private fun saveQr() {
val bmp = generatedBitmap ?: return val bmp = generatedBitmap ?: return
val account = selectedAccount ?: return val target = currentTarget() ?: return
lifecycleScope.launch { lifecycleScope.launch {
val saved = withContext(Dispatchers.IO) { val saved = withContext(Dispatchers.IO) {
try { try {
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_") val safeName = target.name.replace(Regex("[^A-Za-z0-9_]"), "_")
val filename = "${safeName}_PayMV_QR.png" val filename = "${safeName}_PayMV_QR.png"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply { val values = ContentValues().apply {
@@ -412,6 +432,21 @@ class PayMvQrFragment : Fragment() {
_binding = null _binding = null
} }
companion object {
private const val ARG_ACCOUNT_NUMBER = "account_number"
private const val ARG_ACCOUNT_NAME = "account_name"
private const val ARG_BANK = "bank"
/** QR for a contact's account. [bank] is "BML" / "MIB" / "FAHIPAY", as on [BankAccount.bank]. */
fun forContact(accountNumber: String, name: String, bank: String) = PayMvQrFragment().apply {
arguments = Bundle().apply {
putString(ARG_ACCOUNT_NUMBER, accountNumber)
putString(ARG_ACCOUNT_NAME, name)
putString(ARG_BANK, bank)
}
}
}
// ── Account dropdown adapter ────────────────────────────────────────────── // ── Account dropdown adapter ──────────────────────────────────────────────
private inner class QrAccountAdapter( private inner class QrAccountAdapter(
@@ -77,10 +77,10 @@ class CardsFragment : Fragment() {
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null } val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw) val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { if (bmlTarget != null) {
(requireActivity() as HomeActivity).navigateTo( (requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber) R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
) )
} else { } else {
val qr = PaymvQrParser.parse(raw) val qr = PaymvQrParser.parse(raw)
@@ -904,7 +904,9 @@ class CardsFragment : Fragment() {
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) } val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) } val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
val mibInactive = mibItems.filter { !isMibCardActive(it.card.cardStatus) } val mibInactive = mibItems.filter { !isMibCardActive(it.card.cardStatus) }
val all: List<CardItem> = bmlActive + mibActive + bmlInactive + mibInactive val rank = store.loginRank()
val all: List<CardItem> = (bmlActive + mibActive).sortedBy { rank(it.loginTag) } +
(bmlInactive + mibInactive).sortedBy { rank(it.loginTag) }
// Move default BML card to front // Move default BML card to front
cards = if (defaultNum != null) { cards = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum } val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
@@ -10,8 +10,11 @@ import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Base64 import android.util.Base64
import android.annotation.SuppressLint
import android.view.Gravity import android.view.Gravity
import android.view.HapticFeedbackConstants
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
@@ -30,6 +33,7 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlProfile import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.mib.MibProfile import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
import sh.sar.basedbank.ui.login.LoginActivity import sh.sar.basedbank.ui.login.LoginActivity
@@ -359,65 +363,86 @@ class SettingsLoginsFragment : Fragment() {
_binding = null _binding = null
} }
private data class LoginEntry(val key: String, val logoRes: Int, val displayName: String, val onClick: () -> Unit)
private fun buildLoginsSection() { private fun buildLoginsSection() {
val ctx = requireContext() val ctx = requireContext()
val store = CredentialStore(ctx) val store = CredentialStore(ctx)
val container = binding.loginsContainer val container = binding.loginsContainer
container.removeAllViews() container.removeAllViews()
val mibLoginIds = store.getMibLoginIds() val entries = mutableListOf<LoginEntry>()
val bmlLoginIds = store.getBmlLoginIds()
val fahipayLoginIds = store.getFahipayLoginIds()
val mfaisaLoginIds = store.getMfaisaLoginIds()
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty() || mfaisaLoginIds.isNotEmpty()) View.VISIBLE else View.GONE for (loginId in store.getMibLoginIds()) {
for (loginId in mibLoginIds) {
val profile = store.loadMibUserProfile(loginId) val profile = store.loadMibUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
val mibProfiles = store.loadMibProfiles(loginId) val mibProfiles = store.loadMibProfiles(loginId)
addLoginRow(container, R.drawable.mib_logo, displayName) { entries += LoginEntry(CredentialStore.loginKey("mib", loginId), R.drawable.mib_logo, displayName) {
showMibLoginDetails(store, loginId, profile, mibProfiles) showMibLoginDetails(store, loginId, profile, mibProfiles)
} }
} }
for (loginId in bmlLoginIds) { for (loginId in store.getBmlLoginIds()) {
val profile = store.loadBmlUserProfile(loginId) val profile = store.loadBmlUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
val bmlProfiles = store.loadBmlProfiles(loginId) val bmlProfiles = store.loadBmlProfiles(loginId)
addLoginRow(container, R.drawable.bml_logo_vector, displayName) { entries += LoginEntry(CredentialStore.loginKey("bml", loginId), R.drawable.bml_logo_vector, displayName) {
showBmlLoginDetails(store, loginId, profile, bmlProfiles) showBmlLoginDetails(store, loginId, profile, bmlProfiles)
} }
} }
for (loginId in fahipayLoginIds) { for (loginId in store.getFahipayLoginIds()) {
val profile = store.loadFahipayUserProfile(loginId) val profile = store.loadFahipayUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
addLoginRow(container, R.drawable.fahipay_logo, displayName) { entries += LoginEntry(CredentialStore.loginKey("fahipay", loginId), R.drawable.fahipay_logo, displayName) {
showFahipayLoginDetails(store, loginId, profile) showFahipayLoginDetails(store, loginId, profile)
} }
} }
for (loginId in mfaisaLoginIds) { for (loginId in store.getMfaisaLoginIds()) {
val profile = store.loadMfaisaUserProfile(loginId) val profile = store.loadMfaisaUserProfile(loginId)
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name) val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
addLoginRow(container, R.drawable.ooredoo_logo, displayName) { entries += LoginEntry(CredentialStore.loginKey("mfaisa", loginId), R.drawable.ooredoo_logo, displayName) {
showMfaisaLoginDetails(store, loginId, profile) showMfaisaLoginDetails(store, loginId, profile)
} }
} }
val rank = store.loginRank()
val sorted = entries.sortedBy { rank(it.key) }
val draggable = sorted.size > 1
for (entry in sorted) {
addLoginRow(
container, entry.logoRes, entry.displayName, entry.onClick,
dragTag = entry.key.takeIf { draggable },
onReordered = { order ->
store.setLoginOrder(order)
viewModel.resortByLoginOrder()
}
)
}
binding.tvLoginsTitle.visibility = if (entries.isNotEmpty()) View.VISIBLE else View.GONE
} }
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) { private fun addLoginRow(
container: LinearLayout,
logoRes: Int,
displayName: String,
onClick: () -> Unit,
dragTag: String? = null,
onReordered: (List<String>) -> Unit = {}
) {
val ctx = requireContext() val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density val dp = ctx.resources.displayMetrics.density
val row = LinearLayout(ctx).apply { val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL gravity = Gravity.CENTER_VERTICAL
setPadding(0, (12 * dp).toInt(), 0, (12 * dp).toInt()) setPadding((12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt())
isClickable = true; isFocusable = true isClickable = true; isFocusable = true
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground)) val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
background = ta.getDrawable(0); ta.recycle() background = ta.getDrawable(0); ta.recycle()
setOnClickListener { onClick() } setOnClickListener { onClick() }
tag = dragTag
} }
val logo = ImageView(ctx).apply { val logo = ImageView(ctx).apply {
setImageResource(logoRes) setImageResource(logoRes)
@@ -430,9 +455,195 @@ class SettingsLoginsFragment : Fragment() {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
} }
row.addView(logo); row.addView(tvName) row.addView(logo); row.addView(tvName)
if (dragTag != null) {
val handle = ImageView(ctx).apply {
setImageResource(R.drawable.ic_reorder_lines)
scaleType = ImageView.ScaleType.CENTER_INSIDE
contentDescription = getString(R.string.drag_to_reorder)
layoutParams = LinearLayout.LayoutParams((40 * dp).toInt(), (40 * dp).toInt()).apply { marginStart = (4 * dp).toInt() }
}
row.addView(handle)
attachDragHandle(handle, row, container, onReordered)
}
container.addView(row) container.addView(row)
} }
/** Drag [row] vertically within [group] by its [handle]; rows are identified by their String tag. */
@SuppressLint("ClickableViewAccessibility")
private fun attachDragHandle(handle: View, row: View, group: LinearLayout, onReordered: (List<String>) -> Unit) {
val ctx = handle.context
val lift = 8 * ctx.resources.displayMetrics.density
val dragBg = android.graphics.drawable.ColorDrawable(
com.google.android.material.color.MaterialColors.getColor(
ctx, com.google.android.material.R.attr.colorSurfaceContainerHigh, android.graphics.Color.LTGRAY))
var lastY = 0f
var startIndex = 0
var savedBg: android.graphics.drawable.Drawable? = null
handle.setOnTouchListener { v, ev ->
when (ev.actionMasked) {
MotionEvent.ACTION_DOWN -> {
v.parent.requestDisallowInterceptTouchEvent(true)
lastY = ev.rawY
startIndex = group.indexOfChild(row)
row.animate().cancel()
savedBg = row.background
row.background = dragBg
row.translationZ = lift
v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
MotionEvent.ACTION_MOVE -> {
row.translationY += ev.rawY - lastY
lastY = ev.rawY
var index = group.indexOfChild(row)
while (index < group.childCount - 1) {
val next = group.getChildAt(index + 1)
if (row.translationY <= next.height / 2f) break
group.removeView(next); group.addView(next, index)
row.translationY -= next.height
next.translationY = row.height.toFloat()
next.animate().translationY(0f).setDuration(150).start()
index++
}
while (index > 0) {
val prev = group.getChildAt(index - 1)
if (row.translationY >= -prev.height / 2f) break
group.removeView(prev); group.addView(prev, index)
row.translationY += prev.height
prev.translationY = -row.height.toFloat()
prev.animate().translationY(0f).setDuration(150).start()
index--
}
if (index == 0) row.translationY = row.translationY.coerceAtLeast(0f)
if (index == group.childCount - 1) row.translationY = row.translationY.coerceAtMost(0f)
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
v.parent.requestDisallowInterceptTouchEvent(false)
row.animate().translationY(0f).translationZ(0f).setDuration(150)
.withEndAction { row.background = savedBg }
.start()
if (group.indexOfChild(row) != startIndex) {
onReordered((0 until group.childCount).mapNotNull { group.getChildAt(it).tag as? String })
}
}
}
true
}
}
/** A single account row with a visibility toggle; [indent] nests it under a parent profile row (tree view). */
private fun addAccountRow(
ctx: Context,
container: LinearLayout,
dp: Float,
acc: BankAccount,
hiddenAccounts: MutableSet<String>,
indent: Boolean
): Pair<BankAccount, MaterialSwitch> {
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (4 * dp).toInt()
if (indent) it.marginStart = (28 * dp).toInt()
}
}
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
val nameAppearance = if (indent) com.google.android.material.R.style.TextAppearance_Material3_BodySmall
else com.google.android.material.R.style.TextAppearance_Material3_BodyMedium
textCol.addView(TextView(ctx).apply {
text = acc.accountBriefName.ifBlank { acc.accountTypeName.ifBlank { acc.accountNumber } }
setTextAppearance(nameAppearance)
})
val typeLabel = sh.sar.basedbank.util.AccountListParser.from(acc)?.typeLabel
?: if (acc.bank == "BML") sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(acc.accountTypeName)
else acc.accountTypeName.trim()
textCol.addView(TextView(ctx).apply {
text = listOfNotNull(acc.accountNumber, typeLabel.takeIf { it.isNotBlank() }, acc.currencyName.takeIf { it.isNotBlank() })
.joinToString(" · ")
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.6f
})
val toggle = MaterialSwitch(ctx).apply {
isChecked = acc.accountNumber !in hiddenAccounts
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
marginStart = (4 * dp).toInt()
}
}
row.addView(textCol)
row.addView(toggle)
container.addView(row)
return acc to toggle
}
/** Nests [accounts] directly under their parent profile row, without a header — the tree's leaves. */
private fun addNestedAccountRows(
ctx: Context,
container: LinearLayout,
dp: Float,
accounts: List<BankAccount>,
hiddenAccounts: MutableSet<String>
): List<Pair<BankAccount, MaterialSwitch>> =
accounts.map { addAccountRow(ctx, container, dp, it, hiddenAccounts, indent = true) }
/** Builds a headered, flat "Accounts" section for accounts with no profile to nest under. */
private fun addAccountsSection(
ctx: Context,
container: LinearLayout,
dp: Float,
accounts: List<BankAccount>,
hiddenAccounts: MutableSet<String>,
showDivider: Boolean
): List<Pair<BankAccount, MaterialSwitch>> {
if (accounts.isEmpty()) return emptyList()
if (showDivider) {
container.addView(View(ctx).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
}
setBackgroundColor(0x1F000000)
})
}
container.addView(TextView(ctx).apply {
text = getString(R.string.accounts)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (8 * dp).toInt()
}
})
return accounts.map { addAccountRow(ctx, container, dp, it, hiddenAccounts, indent = false) }
}
/**
* Unchecks and disables account toggles whose parent profile is hidden, and restores the
* account's own choice once the profile is shown again. [hiddenAccounts] is left untouched —
* the account listeners ignore changes made while a toggle is disabled.
*/
private fun syncAccountToggles(
accountRows: List<Pair<BankAccount, MaterialSwitch>>,
hiddenAccounts: Set<String>,
isParentHidden: (BankAccount) -> Boolean
) {
accountRows.forEach { (acc, toggle) ->
if (isParentHidden(acc)) {
toggle.isEnabled = false
toggle.isChecked = false
} else {
toggle.isChecked = acc.accountNumber !in hiddenAccounts
toggle.isEnabled = true
}
}
}
/** Merges this login's account-hide choices into the global hidden-accounts set, leaving other logins untouched. */
private fun persistHiddenAccounts(store: CredentialStore, scopedAccounts: List<BankAccount>, hiddenAccounts: Set<String>) {
val scopedNumbers = scopedAccounts.map { it.accountNumber }.toSet()
store.setHiddenAccountNumbers((store.getHiddenAccountNumbers() - scopedNumbers) + hiddenAccounts)
}
private fun showMibLoginDetails( private fun showMibLoginDetails(
store: CredentialStore, store: CredentialStore,
loginId: String, loginId: String,
@@ -443,6 +654,10 @@ class SettingsLoginsFragment : Fragment() {
val dp = ctx.resources.displayMetrics.density val dp = ctx.resources.displayMetrics.density
val originalHidden = store.getHiddenMibProfileIds(loginId) val originalHidden = store.getHiddenMibProfileIds(loginId)
val hidden = originalHidden.toMutableSet() val hidden = originalHidden.toMutableSet()
val app = requireActivity().application as BasedBankApp
val loginAccounts = app.mibAccounts.filter { it.loginTag == "mib_$loginId" }
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
val scroll = android.widget.ScrollView(ctx) val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply { val container = LinearLayout(ctx).apply {
@@ -485,7 +700,10 @@ class SettingsLoginsFragment : Fragment() {
}) })
} }
// Build toggle rows — wired up after dialog.show() so we can reference the Save button // Build toggle rows — wired up after dialog.show() so we can reference the Save button.
// Each profile's own accounts nest directly beneath it (tree view), since one profile
// can have multiple accounts.
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
val toggleRows = mibProfiles.map { p -> val toggleRows = mibProfiles.map { p ->
val row = LinearLayout(ctx).apply { val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
@@ -526,16 +744,25 @@ class SettingsLoginsFragment : Fragment() {
row.addView(pencil) row.addView(pencil)
row.addView(toggle) row.addView(toggle)
container.addView(row) container.addView(row)
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
p to toggle p to toggle
} }
// Accounts that don't belong to any known profile still need to be reachable.
val unassignedAccounts = loginAccounts.filter { acc -> mibProfiles.none { it.profileId == acc.profileId } }
accountRows += addAccountsSection(
ctx, container, dp, unassignedAccounts, hiddenAccounts,
showDivider = mibProfiles.isNotEmpty() || profile != null
)
fun updateToggleStates(saveBtn: android.widget.Button) { fun updateToggleStates(saveBtn: android.widget.Button) {
val visibleCount = mibProfiles.count { it.profileId !in hidden } val visibleCount = mibProfiles.count { it.profileId !in hidden }
toggleRows.forEach { (p, toggle) -> toggleRows.forEach { (p, toggle) ->
// Disable the sole remaining visible toggle so it can't be turned off // Disable the sole remaining visible toggle so it can't be turned off
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1) toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
} }
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1 syncAccountToggles(accountRows, hiddenAccounts) { it.profileId in hidden }
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
} }
val dialog = MaterialAlertDialogBuilder(ctx) val dialog = MaterialAlertDialogBuilder(ctx)
@@ -559,11 +786,22 @@ class SettingsLoginsFragment : Fragment() {
} }
} }
accountRows.forEach { (acc, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden parent profile
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
updateToggleStates(saveBtn)
}
}
saveBtn.setOnClickListener { saveBtn.setOnClickListener {
store.setHiddenMibProfileIds(loginId, hidden) store.setHiddenMibProfileIds(loginId, hidden)
clearAllCaches(ctx) persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
dialog.dismiss() dialog.dismiss()
(activity as? HomeActivity)?.relogin() // Hiding is applied offline; only profiles that were just unhidden get fetched
val home = activity as? HomeActivity
home?.applyVisibility()
home?.fetchEnabledMibProfiles(loginId, originalHidden - hidden)
} }
} }
@@ -585,6 +823,10 @@ class SettingsLoginsFragment : Fragment() {
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden) if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
} }
val originalHidden = hidden.toSet() val originalHidden = hidden.toSet()
val app = requireActivity().application as BasedBankApp
val loginAccounts = app.bmlAccounts.filter { it.loginTag == "bml_$loginId" }
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
val scroll = android.widget.ScrollView(ctx) val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply { val container = LinearLayout(ctx).apply {
@@ -630,6 +872,9 @@ class SettingsLoginsFragment : Fragment() {
}) })
} }
// Each profile's own accounts nest directly beneath it (tree view), since one profile
// can have multiple accounts.
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
val toggleRows = bmlProfiles.map { p -> val toggleRows = bmlProfiles.map { p ->
val avatarIv = makeCircleAvatarView(ctx, 36) val avatarIv = makeCircleAvatarView(ctx, 36)
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId)) val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
@@ -677,15 +922,24 @@ class SettingsLoginsFragment : Fragment() {
row.addView(pencil) row.addView(pencil)
row.addView(toggle) row.addView(toggle)
container.addView(row) container.addView(row)
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
p to toggle p to toggle
} }
// Accounts that don't belong to any known profile still need to be reachable.
val unassignedAccounts = loginAccounts.filter { acc -> bmlProfiles.none { it.profileId == acc.profileId } }
accountRows += addAccountsSection(
ctx, container, dp, unassignedAccounts, hiddenAccounts,
showDivider = bmlProfiles.isNotEmpty() || profile != null
)
fun updateToggleStates(saveBtn: android.widget.Button) { fun updateToggleStates(saveBtn: android.widget.Button) {
val visibleCount = bmlProfiles.count { it.profileId !in hidden } val visibleCount = bmlProfiles.count { it.profileId !in hidden }
toggleRows.forEach { (_, toggle) -> toggleRows.forEach { (_, toggle) ->
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1) toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
} }
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1 syncAccountToggles(accountRows, hiddenAccounts) { it.profileId in hidden }
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
} }
val dialog = MaterialAlertDialogBuilder(ctx) val dialog = MaterialAlertDialogBuilder(ctx)
@@ -720,11 +974,22 @@ class SettingsLoginsFragment : Fragment() {
} }
} }
accountRows.forEach { (acc, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden parent profile
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
updateToggleStates(saveBtn)
}
}
saveBtn.setOnClickListener { saveBtn.setOnClickListener {
store.setHiddenBmlProfileIds(loginId, hidden) store.setHiddenBmlProfileIds(loginId, hidden)
clearAllCaches(ctx) persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
dialog.dismiss() dialog.dismiss()
(activity as? HomeActivity)?.relogin() // Hiding is applied offline; only profiles that were just unhidden get fetched
val home = activity as? HomeActivity
home?.applyVisibility()
home?.fetchEnabledBmlProfiles(loginId, originalHidden - hidden)
} }
} }
@@ -947,6 +1212,10 @@ class SettingsLoginsFragment : Fragment() {
val dp = ctx.resources.displayMetrics.density val dp = ctx.resources.displayMetrics.density
val hide = viewModel.hideAmounts.value ?: false val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••" val masked = "••••••"
val app = requireActivity().application as BasedBankApp
val loginAccounts = app.fahipayAccounts.filter { it.loginTag == "fahipay_$loginId" }
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
val scroll = android.widget.ScrollView(ctx) val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply { val container = LinearLayout(ctx).apply {
@@ -997,14 +1266,37 @@ class SettingsLoginsFragment : Fragment() {
}) })
} }
MaterialAlertDialogBuilder(ctx) val accountRows = addAccountsSection(ctx, container, dp, loginAccounts, hiddenAccounts, showDivider = true)
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(getString(R.string.fahipay_name)) .setTitle(getString(R.string.fahipay_name))
.setView(scroll) .setView(scroll)
.setPositiveButton(R.string.close, null) .apply {
.setNegativeButton(R.string.settings_logout) { _, _ -> if (loginAccounts.isNotEmpty()) setPositiveButton(R.string.save, null)
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } setNeutralButton(R.string.close, null)
setNegativeButton(R.string.settings_logout) { _, _ ->
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
}
} }
.show() .show()
if (loginAccounts.isNotEmpty()) {
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
saveBtn.isEnabled = false
accountRows.forEach { (acc, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
saveBtn.isEnabled = hiddenAccounts != originalHiddenAccounts
}
}
saveBtn.setOnClickListener {
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
dialog.dismiss()
(activity as? HomeActivity)?.applyVisibility()
}
}
} }
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) { private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
@@ -1087,6 +1379,8 @@ class SettingsLoginsFragment : Fragment() {
val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId) val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId)
val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet() val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet()
val originalHidden = hidden.toSet() val originalHidden = hidden.toSet()
val originalHiddenAccounts = pockets.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
// The user-visible "profiles" are: M-Faisa (every non-PayPal pocket) and PayPal (if linked). // The user-visible "profiles" are: M-Faisa (every non-PayPal pocket) and PayPal (if linked).
// Each toggle covers the set of pocket account numbers that belong to that profile. // Each toggle covers the set of pocket account numbers that belong to that profile.
@@ -1142,6 +1436,9 @@ class SettingsLoginsFragment : Fragment() {
}) })
} }
// Each group's own pockets nest directly beneath it (tree view), since a group
// ("M-Faisa" / "PayPal") can hold multiple pocket accounts.
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
val toggleRows = profileRows.map { row -> val toggleRows = profileRows.map { row ->
val v = LinearLayout(ctx).apply { val v = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
@@ -1164,6 +1461,7 @@ class SettingsLoginsFragment : Fragment() {
v.addView(label) v.addView(label)
v.addView(toggle) v.addView(toggle)
container.addView(v) container.addView(v)
accountRows += addNestedAccountRows(ctx, container, dp, pockets.filter { it.accountNumber in row.pocketIds }, hiddenAccounts)
row to toggle row to toggle
} }
@@ -1172,7 +1470,8 @@ class SettingsLoginsFragment : Fragment() {
toggleRows.forEach { (_, toggle) -> toggleRows.forEach { (_, toggle) ->
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1) toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
} }
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1 syncAccountToggles(accountRows, hiddenAccounts) { it.accountNumber in hidden }
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
} }
val dialog = MaterialAlertDialogBuilder(ctx) val dialog = MaterialAlertDialogBuilder(ctx)
@@ -1199,11 +1498,20 @@ class SettingsLoginsFragment : Fragment() {
} }
} }
accountRows.forEach { (acc, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden pocket group
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
updateToggleStates(saveBtn)
}
}
saveBtn.setOnClickListener { saveBtn.setOnClickListener {
// All pockets come back in one login response, so hiding/unhiding never needs a request
store.setHiddenMfaisaPocketIds(loginId, hidden) store.setHiddenMfaisaPocketIds(loginId, hidden)
clearAllCaches(ctx) persistHiddenAccounts(store, pockets, hiddenAccounts)
dialog.dismiss() dialog.dismiss()
(activity as? HomeActivity)?.relogin() (activity as? HomeActivity)?.applyVisibility()
} }
} }
} }
@@ -40,9 +40,11 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.mib.MibIpsAccountInfo import sh.sar.basedbank.api.mib.MibIpsAccountInfo
import sh.sar.basedbank.databinding.FragmentTransferBinding import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ItemPickerRowBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
@@ -92,6 +94,7 @@ class TransferFragment : Fragment() {
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>() private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
private var accountDropdownAdapter: AccountDropdownAdapter? = null private var accountDropdownAdapter: AccountDropdownAdapter? = null
private var contactDropdownAdapter: ContactDropdownAdapter? = null
/** /**
* Owns everything BML-specific: sessions, the transfer itself, the business-profile OTP * Owns everything BML-specific: sessions, the transfer itself, the business-profile OTP
@@ -172,13 +175,13 @@ class TransferFragment : Fragment() {
if (result.resultCode != Activity.RESULT_OK) return if (result.resultCode != Activity.RESULT_OK) return
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return
// BML card/gateway QR — hand off to dedicated payment screen // BML card/gateway/POS QR — hand off to dedicated payment screen
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw) val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { if (bmlTarget != null) {
val fromCard = selectedAccount?.takeIf { val fromCard = selectedAccount?.takeIf {
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
} }
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, fromCard?.accountNumber)) (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, fromCard?.accountNumber))
return return
} }
@@ -305,6 +308,9 @@ class TransferFragment : Fragment() {
setupFromDropdown() setupFromDropdown()
setupAccountLookup() setupAccountLookup()
// Contact search dropdown on the To field needs viewModel.contacts populated —
// otherwise it stays empty until the contacts tab or picker sheet is opened first.
(activity as? HomeActivity)?.loadAllContacts()
viewModel.hideAmounts.observe(viewLifecycleOwner) { viewModel.hideAmounts.observe(viewLifecycleOwner) {
accountDropdownAdapter?.notifyDataSetChanged() accountDropdownAdapter?.notifyDataSetChanged()
@@ -327,7 +333,7 @@ class TransferFragment : Fragment() {
// MFAISA source + a phone-number pick (e.g. a tagged M-Faisa recent) — re-run the // MFAISA source + a phone-number pick (e.g. a tagged M-Faisa recent) — re-run the
// basicBeneDetails lookup so the recipient gets fully resolved before Send is enabled. // basicBeneDetails lookup so the recipient gets fully resolved before Send is enabled.
if (selectedAccount?.bank == "MFAISA") { if (selectedAccount?.bank == "MFAISA") {
binding.etTo.setText(accountNumber) binding.etTo.setText(accountNumber, false)
mfaisaHandler().searchRecipient(accountNumber) mfaisaHandler().searchRecipient(accountNumber)
return@setFragmentResultListener return@setFragmentResultListener
} }
@@ -336,18 +342,8 @@ class TransferFragment : Fragment() {
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B" val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH) val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash) prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
if (selectedAccount == null) { selectDefaultSourceIfNone()
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber() focusAmount()
if (defaultNum != null) {
val defaultAcc = viewModel.accounts.value?.firstOrNull { it.accountNumber == defaultNum }
if (defaultAcc != null) {
selectedAccount = defaultAcc
updateAmountPrefix(defaultAcc)
showFromCard(defaultAcc)
updateTransferButton()
}
}
}
} }
binding.btnPickContact.setOnClickListener { binding.btnPickContact.setOnClickListener {
@@ -408,7 +404,7 @@ class TransferFragment : Fragment() {
binding.cardToInfo.visibility = View.VISIBLE binding.cardToInfo.visibility = View.VISIBLE
if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null) if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null)
} else if (savedToText.isNotEmpty()) { } else if (savedToText.isNotEmpty()) {
binding.etTo.setText(savedToText) binding.etTo.setText(savedToText, false)
} }
if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount) if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount)
if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks) if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks)
@@ -537,7 +533,7 @@ class TransferFragment : Fragment() {
resolvedToOwnAccount = null resolvedToOwnAccount = null
binding.cardToInfo.visibility = View.GONE binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE binding.tilTo.visibility = View.VISIBLE
binding.etTo.setText("") binding.etTo.setText("", false)
} }
} else { } else {
binding.tilTo.hint = getString(R.string.transfer_to) binding.tilTo.hint = getString(R.string.transfer_to)
@@ -551,7 +547,7 @@ class TransferFragment : Fragment() {
resolvedToOwnAccount = null resolvedToOwnAccount = null
binding.cardToInfo.visibility = View.GONE binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE binding.tilTo.visibility = View.VISIBLE
binding.etTo.setText("") binding.etTo.setText("", false)
} }
} }
// The picker and QR-scan icons live alongside the tilTo input. Keep them in sync with // The picker and QR-scan icons live alongside the tilTo input. Keep them in sync with
@@ -720,13 +716,12 @@ class TransferFragment : Fragment() {
} }
private fun setupAccountLookup() { private fun setupAccountLookup() {
binding.tilTo.setEndIconOnClickListener { binding.tilTo.setEndIconOnClickListener { searchTo() }
// M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails) binding.etTo.setOnEditorActionListener { _, actionId, _ ->
if (selectedAccount?.bank == "MFAISA") { if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH) {
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty()) searchTo()
} else { true
lookupAccount() } else false
}
} }
binding.btnClearToInfo.setOnClickListener { binding.btnClearToInfo.setOnClickListener {
@@ -764,6 +759,62 @@ class TransferFragment : Fragment() {
updateTransferButton() updateTransferButton()
} }
} }
setupContactDropdown()
}
private fun searchTo() {
// M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails)
if (selectedAccount?.bank == "MFAISA") {
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
} else {
lookupAccount()
}
}
/** Moves focus to the amount field and brings up the keyboard once a recipient is resolved. */
internal fun focusAmount() {
val et = binding.etAmount
// Posted so it runs after the To-row visibility changes and any closing picker sheet
et.post {
if (_binding == null) return@post
et.requestFocus()
et.setSelection(et.text?.length ?: 0)
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
imm.showSoftInput(et, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
}
}
/** Live "search contacts as you type" dropdown on the To field (shown once 3+ chars are entered). */
private fun setupContactDropdown() {
val adapter = ContactDropdownAdapter(requireContext()) { selectedAccount?.bank == "MFAISA" }
contactDropdownAdapter = adapter
binding.etTo.setAdapter(adapter)
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
adapter.updateSource(contacts)
}
binding.etTo.setOnItemClickListener { _, _, position, _ ->
val contact = adapter.getContact(position) ?: return@setOnItemClickListener
prefillToDirectly(
accountNumber = contact.benefAccount,
displayName = contact.benefNickName,
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
colorHex = contact.bankColor,
imageHash = contact.customerImgHash
)
selectDefaultSourceIfNone()
focusAmount()
}
}
/** Picks the user's default account as the source when a recipient is chosen before any source. */
private fun selectDefaultSourceIfNone() {
if (selectedAccount != null) return
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber() ?: return
val defaultAcc = viewModel.accounts.value?.firstOrNull { it.accountNumber == defaultNum } ?: return
selectSourceAccount(defaultAcc)
} }
private fun lookupAccount() { private fun lookupAccount() {
@@ -801,6 +852,7 @@ class TransferFragment : Fragment() {
colorHex = matchedContact.bankColor, colorHex = matchedContact.bankColor,
imageHash = matchedContact.customerImgHash imageHash = matchedContact.customerImgHash
) )
focusAmount()
return return
} }
@@ -868,7 +920,8 @@ class TransferFragment : Fragment() {
resolvedRecipientName = info.accountName resolvedRecipientName = info.accountName
resolvedBankName = info.bankId resolvedBankName = info.bankId
resolvedDestCurrency = info.currency resolvedDestCurrency = info.currency
savedToSubtitle = "${info.accountNumber} · ${info.bankId}" savedToSubtitle = listOfNotNull(info.accountNumber, info.bankId, info.currency.takeIf { it.isNotBlank() })
.joinToString(" · ")
savedToColorHex = colorHex savedToColorHex = colorHex
savedToImageHash = when { savedToImageHash = when {
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
@@ -880,7 +933,7 @@ class TransferFragment : Fragment() {
showToCard(matchedAcc) showToCard(matchedAcc)
} else { } else {
binding.tvToAccountName.text = displayName binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}" binding.tvToBankBic.text = savedToSubtitle
binding.tvToAccountDetails.visibility = View.GONE binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
@@ -892,6 +945,7 @@ class TransferFragment : Fragment() {
binding.cardToInfo.visibility = View.VISIBLE binding.cardToInfo.visibility = View.VISIBLE
updateTransferButton() updateTransferButton()
saveToRecents(info) saveToRecents(info)
focusAmount()
when { when {
matchedAcc?.profileImageHash != null -> matchedAcc?.profileImageHash != null ->
@@ -975,7 +1029,7 @@ class TransferFragment : Fragment() {
binding.btnPickContact.visibility = View.VISIBLE binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE binding.btnScanQr.visibility = View.VISIBLE
binding.tilTo.error = null binding.tilTo.error = null
binding.etTo.setText(accountNumber) binding.etTo.setText(accountNumber, false)
lookupAccount() lookupAccount()
} }
@@ -1512,7 +1566,7 @@ class TransferFragment : Fragment() {
binding.tilTo.visibility = View.VISIBLE binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE binding.btnScanQr.visibility = View.VISIBLE
binding.etTo.setText("") binding.etTo.setText("", false)
binding.tilTo.error = null binding.tilTo.error = null
binding.tilAmount.error = null binding.tilAmount.error = null
} }
@@ -1782,4 +1836,59 @@ class TransferFragment : Fragment() {
override fun convertResultToString(r: Any?) = "" override fun convertResultToString(r: Any?) = ""
} }
} }
/** Filters [source] contacts by nickname/name/account number, shown once the query is 3+ chars. */
private inner class ContactDropdownAdapter(
private val context: Context,
private val isDisabled: () -> Boolean
) : BaseAdapter(), Filterable {
private var source: List<BankContact> = emptyList()
private var filtered: List<BankContact> = emptyList()
fun updateSource(contacts: List<BankContact>) {
source = contacts
}
fun getContact(position: Int): BankContact? = filtered.getOrNull(position)
override fun getCount() = filtered.size
override fun getItem(position: Int) = filtered[position]
override fun getItemId(position: Int) = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val contact = filtered[position]
val b = if (convertView?.tag is ItemPickerRowBinding) {
convertView.tag as ItemPickerRowBinding
} else {
ItemPickerRowBinding.inflate(LayoutInflater.from(context), parent, false).also { it.root.tag = it }
}
b.tvPrimary.text = contact.benefNickName
b.tvSecondary.text = "${contact.benefBankName} · ${contact.benefAccount}"
b.tvBalance.visibility = View.GONE
b.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
b.ivIcon.setImageBitmap(makeInitialsBitmap(contact.benefNickName, contact.bankColor))
return b.root
}
override fun getFilter() = object : Filter() {
override fun performFiltering(constraint: CharSequence?): FilterResults {
val query = constraint?.toString()?.trim().orEmpty()
val matches = if (isDisabled() || query.length < 3) emptyList() else source.filter {
it.benefNickName.contains(query, ignoreCase = true) ||
it.benefName.contains(query, ignoreCase = true) ||
it.benefAccount.contains(query, ignoreCase = true)
}.take(8)
return FilterResults().apply { values = matches; count = matches.size }
}
@Suppress("UNCHECKED_CAST")
override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
filtered = results?.values as? List<BankContact> ?: emptyList()
notifyDataSetChanged()
}
override fun convertResultToString(r: Any?) = ""
}
}
} }
@@ -18,6 +18,7 @@ import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlOtpChannel import sh.sar.basedbank.api.bml.BmlOtpChannel
import sh.sar.basedbank.api.bml.BmlQrPayClient import sh.sar.basedbank.api.bml.BmlQrPayClient
import sh.sar.basedbank.api.bml.BmlQrPayInfo import sh.sar.basedbank.api.bml.BmlQrPayInfo
import sh.sar.basedbank.api.bml.BmlQrPayLookupException
import sh.sar.basedbank.api.bml.BmlQrPayResult import sh.sar.basedbank.api.bml.BmlQrPayResult
import sh.sar.basedbank.api.bml.BmlSession import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.bml.BmlTransferClient import sh.sar.basedbank.api.bml.BmlTransferClient
@@ -31,6 +32,7 @@ import sh.sar.basedbank.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment import sh.sar.basedbank.ui.home.TransferFragment
import sh.sar.basedbank.ui.home.TransferReceiptData import sh.sar.basedbank.ui.home.TransferReceiptData
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.RecentPick import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.Totp import sh.sar.basedbank.util.Totp
@@ -173,9 +175,10 @@ class BmlTransferHandler(
fun lookupQrMerchant(qrUrl: String) { fun lookupQrMerchant(qrUrl: String) {
qrLookupAttempted = true qrLookupAttempted = true
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") // Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount and
val base64Url = android.util.Base64.encodeToString( // need the extra pre-initiate POST; ebanking qrpay URLs do not.
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP) gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
val session = app.anyBmlSession() ?: return val session = app.anyBmlSession() ?: return
// Lock the "To" input row while loading // Lock the "To" input row while loading
@@ -185,14 +188,20 @@ class BmlTransferHandler(
host?.setRefreshing(true) host?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch { fragment.viewLifecycleOwner.lifecycleScope.launch {
val info = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
try { BmlQrPayClient().lookupPayRequest(session, base64Url) } runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
catch (_: Exception) { null }
} }
host?.setRefreshing(false) host?.setRefreshing(false)
val info = result.getOrNull()
if (info == null) { if (info == null) {
Toast.makeText(ctx, R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show() // An expired or rejected QR is BML telling us something specific — show its own
fragment.requireActivity().onBackPressedDispatcher.onBackPressed() // wording and stay put with the To row restored, rather than bouncing the user out
// of the screen they just scanned from.
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
?: ctx.getString(R.string.bml_qr_lookup_failed)
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
fragment.resetToFieldVisibility()
onStateChanged()
return@launch return@launch
} }
qrInfo = info qrInfo = info
@@ -194,5 +194,6 @@ class FahipayTransferHandler(
colorHex = "#FF6B00", colorHex = "#FF6B00",
imageHash = null imageHash = null
) )
fragment.focusAmount()
} }
} }
@@ -121,6 +121,7 @@ class MfaisaTransferHandler(
} else { } else {
recipient = result recipient = result
showResolvedRecipient(result) showResolvedRecipient(result)
fragment.focusAmount()
} }
} catch (_: MfaisaRecipientNotFoundException) { } catch (_: MfaisaRecipientNotFoundException) {
binding.tilTo.error = "No M-Faisa wallet found for this number" binding.tilTo.error = "No M-Faisa wallet found for this number"
@@ -212,6 +212,7 @@ class CredentialsFragment : Fragment() {
val remaining = 30 - secondsInPeriod val remaining = 30 - secondsInPeriod
binding.tvOtpCode.text = otp binding.tvOtpCode.text = otp
binding.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
binding.otpTimer.max = 30 binding.otpTimer.max = 30
binding.otpTimer.progress = remaining binding.otpTimer.progress = remaining
binding.cardOtp.visibility = View.VISIBLE binding.cardOtp.visibility = View.VISIBLE
@@ -13,6 +13,8 @@ data class ContactDisplay(
val network: TransferNetwork, val network: TransferNetwork,
val bankColor: String, val bankColor: String,
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row
val bankName: String?, // shown in the contact details sheet; null = hide row
val currency: String?, // shown in the contact details sheet; null = hide row
val imageHash: String?, val imageHash: String?,
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager) val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen
@@ -14,6 +14,11 @@ import javax.crypto.spec.GCMParameterSpec
class CredentialStore(context: Context) { class CredentialStore(context: Context) {
companion object {
/** Same format as BankAccount.loginTag / MibCard.loginTag, e.g. "bml_<loginId>". */
fun loginKey(bank: String, loginId: String) = "${bank}_$loginId"
}
private val prefs = context.getSharedPreferences("credential_store", Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences("credential_store", Context.MODE_PRIVATE)
private val keyAlias = "basedbank_credential_key" private val keyAlias = "basedbank_credential_key"
private val transformation = "AES/GCM/NoPadding" private val transformation = "AES/GCM/NoPadding"
@@ -23,6 +28,37 @@ class CredentialStore(context: Context) {
data class FahipayCredentials(val idCard: String, val password: String) data class FahipayCredentials(val idCard: String, val password: String)
data class MfaisaCredentials(val msisdn: String, val pin: String) data class MfaisaCredentials(val msisdn: String, val pin: String)
// ── Cross-bank login order (keys are login tags, see loginKey) ──────────
/** User-chosen order of all logins; logins not yet ordered (e.g. newly added) follow, bank by bank. */
fun getLoginOrder(): List<String> {
val all = getMibLoginIds().map { loginKey("mib", it) } +
getBmlLoginIds().map { loginKey("bml", it) } +
getFahipayLoginIds().map { loginKey("fahipay", it) } +
getMfaisaLoginIds().map { loginKey("mfaisa", it) }
val saved = try {
val arr = org.json.JSONArray(prefs.getString("login_order", null) ?: "[]")
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
return saved.filter { it in all } + all.filter { it !in saved }
}
/** Position of a login tag in [getLoginOrder]; unknown tags sort last. */
fun loginRank(): (String) -> Int {
val order = getLoginOrder()
return { tag -> order.indexOf(tag).let { if (it < 0) Int.MAX_VALUE else it } }
}
/** Saves the combined order and keeps each bank's own login list in the same relative order. */
fun setLoginOrder(keys: List<String>) {
prefs.edit().putString("login_order", org.json.JSONArray(keys).toString()).apply()
fun idsFor(bank: String) = keys.filter { it.startsWith("${bank}_") }.map { it.removePrefix("${bank}_") }
setMibLoginIds(idsFor("mib"))
setBmlLoginIds(idsFor("bml"))
setFahipayLoginIds(idsFor("fahipay"))
setMfaisaLoginIds(idsFor("mfaisa"))
}
// ── MIB login credentials (multi-login, keyed by loginId = username) ───── // ── MIB login credentials (multi-login, keyed by loginId = username) ─────
fun getMibLoginIds(): List<String> { fun getMibLoginIds(): List<String> {
@@ -36,6 +72,11 @@ class CredentialStore(context: Context) {
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty() fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
fun setMibLoginIds(order: List<String>) {
if (order.toSet() != getMibLoginIds().toSet()) return
prefs.edit().putString("mib_login_ids", org.json.JSONArray(order).toString()).apply()
}
private fun addMibLoginId(loginId: String) { private fun addMibLoginId(loginId: String) {
val ids = getMibLoginIds().toMutableList() val ids = getMibLoginIds().toMutableList()
if (loginId !in ids) { if (loginId !in ids) {
@@ -160,6 +201,11 @@ class CredentialStore(context: Context) {
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty() fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
fun setBmlLoginIds(order: List<String>) {
if (order.toSet() != getBmlLoginIds().toSet()) return
prefs.edit().putString("bml_login_ids", org.json.JSONArray(order).toString()).apply()
}
private fun addBmlLoginId(loginId: String) { private fun addBmlLoginId(loginId: String) {
val ids = getBmlLoginIds().toMutableList() val ids = getBmlLoginIds().toMutableList()
if (loginId !in ids) { if (loginId !in ids) {
@@ -316,6 +362,11 @@ class CredentialStore(context: Context) {
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty() fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
fun setFahipayLoginIds(order: List<String>) {
if (order.toSet() != getFahipayLoginIds().toSet()) return
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(order).toString()).apply()
}
private fun addFahipayLoginId(loginId: String) { private fun addFahipayLoginId(loginId: String) {
val ids = getFahipayLoginIds().toMutableList() val ids = getFahipayLoginIds().toMutableList()
if (loginId !in ids) { if (loginId !in ids) {
@@ -473,6 +524,11 @@ class CredentialStore(context: Context) {
fun hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty() fun hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty()
fun setMfaisaLoginIds(order: List<String>) {
if (order.toSet() != getMfaisaLoginIds().toSet()) return
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(order).toString()).apply()
}
private fun addMfaisaLoginId(loginId: String) { private fun addMfaisaLoginId(loginId: String) {
val ids = getMfaisaLoginIds().toMutableList() val ids = getMfaisaLoginIds().toMutableList()
if (loginId !in ids) { if (loginId !in ids) {
@@ -764,6 +820,15 @@ class CredentialStore(context: Context) {
fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) = fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) =
prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply() prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply()
// ── Per-account visibility (account numbers are globally unique) ─────────
/** Returns the set of account numbers the user has chosen to hide, across all logins. */
fun getHiddenAccountNumbers(): Set<String> =
prefs.getStringSet("hidden_account_numbers", emptySet()) ?: emptySet()
fun setHiddenAccountNumbers(accountNumbers: Set<String>) =
prefs.edit().putStringSet("hidden_account_numbers", accountNumbers).apply()
// ── Crypto primitives ───────────────────────────────────────────────────── // ── Crypto primitives ─────────────────────────────────────────────────────
private fun getOrCreateKey(): SecretKey { private fun getOrCreateKey(): SecretKey {
@@ -9,22 +9,58 @@ data class PaymvQrData(
object PaymvQrParser { object PaymvQrParser {
private const val BML_GATEWAY_PREFIX = "https://pay.bml.com.mv/app/"
private const val BML_EBANKING_PREFIX = "https://ebanking.bankofmaldives.com.mv/qrpay/"
private const val BML_POS_DOMAIN = "mv.com.bml.qtr"
/** /**
* Returns the BML gateway URL if [raw] is or contains one, otherwise null. * Returns the value to hand to the BML QR payment flow, or null when [raw] is not a BML QR.
* Handles both plain URL QRs and combined EMV QRs (e.g. Fahipay+BML card QR). *
* For combined EMV QRs the URL is parsed from TLV (root tag 35 → sub-tag 20 → sub-sub-tag 01) * Three shapes exist:
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes. * - plain URL QRs — the QR text is the gateway short link or an ebanking `qrpay` link;
* - combined EMV QRs (e.g. Fahipay+BML card QRs), which carry the full gateway URL in TLV at
* `35` → `20` → `01`;
* - BML POS QRs (supplementary data domain `mv.com.bml.qtr`), where the same TLV path holds a
* bare reference such as `02:<32 hex>` rather than a URL. Those return the whole EMV payload,
* which [bmlPayRequestKey] then reduces back to the reference the lookup wants.
*
* The TLV is walked rather than regex-matched so a URL cannot greedily swallow the EMV tags
* that follow it.
*/ */
fun extractBmlGatewayUrl(raw: String): String? { fun bmlQrPayTarget(raw: String): String? {
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw if (raw.startsWith(BML_GATEWAY_PREFIX) || raw.startsWith(BML_EBANKING_PREFIX)) return raw
return try { val ref = bmlQrReference(raw) ?: return null
val root = parseTlv(raw) if (ref.startsWith("https://")) return ref.takeIf { it.startsWith(BML_GATEWAY_PREFIX) }
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null // A bare reference only means a BML POS QR when the supplementary domain says so; anything
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null // else keeps falling through to the PayMV parse, as it did before POS QRs existed.
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") } return raw.takeIf { supplementaryDomain(it)?.startsWith(BML_POS_DOMAIN) == true }
} catch (_: Exception) { }
null
} /** TLV `80` → `00` — the supplementary data domain (`mv.favara.mpqr`, `mv.com.bml.qtr`, …). */
private fun supplementaryDomain(raw: String): String? = try {
parseTlv(raw)["80"]?.let { parseTlv(it) }?.get("00")
} catch (_: Exception) {
null
}
/** TLV `35` → `20` → `01` — the BML payment reference carried inside an EMV QR. */
private fun bmlQrReference(raw: String): String? = try {
val merchantInfo = parseTlv(raw)["35"]?.let { parseTlv(it) }
val inner = merchantInfo?.get("20")?.let { parseTlv(it) }
inner?.get("01")?.takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
/**
* The `payrequest` lookup key for [target] (a value returned by [bmlQrPayTarget]), to be
* Base64-encoded into the URL. URL QRs resolve under the URL itself; POS QRs resolve under the
* bare `35` → `20` → `01` reference (the whole EMV payload is rejected with code 112,
* "Unsupported payment link").
*/
fun bmlPayRequestKey(target: String): String {
if (target.startsWith("https://")) return target
return bmlQrReference(target) ?: target
} }
fun parse(raw: String): PaymvQrData? { fun parse(raw: String): PaymvQrData? {
@@ -9,10 +9,11 @@ object Totp {
/** /**
* Generate a 6-digit TOTP code from a Base32-encoded secret (RFC 6238 / RFC 4226). * Generate a 6-digit TOTP code from a Base32-encoded secret (RFC 6238 / RFC 4226).
* Uses HmacSHA1, 30-second window, 6 digits — matching standard authenticator apps. * Uses HmacSHA1, 30-second window, 6 digits — matching standard authenticator apps.
* [periodOffset] shifts the time window, e.g. 1 yields the next code.
*/ */
fun generate(base32Secret: String, digits: Int = 6, periodSeconds: Long = 30): String { fun generate(base32Secret: String, digits: Int = 6, periodSeconds: Long = 30, periodOffset: Long = 0): String {
val key = base32Decode(base32Secret.uppercase().replace(" ", "").replace("-", "")) val key = base32Decode(base32Secret.uppercase().replace(" ", "").replace("-", ""))
val counter = System.currentTimeMillis() / 1000L / periodSeconds val counter = System.currentTimeMillis() / 1000L / periodSeconds + periodOffset
val otp = hotp(key, counter, digits) val otp = hotp(key, counter, digits)
return otp.toString().padStart(digits, '0') return otp.toString().padStart(digits, '0')
} }
@@ -15,6 +15,8 @@ object BmlContactParser {
network = TransferNetwork.BML, network = TransferNetwork.BML,
bankColor = contact.bankColor, bankColor = contact.bankColor,
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}", detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
imageHash = contact.customerImgHash, imageHash = contact.customerImgHash,
profileId = contact.profileId, profileId = contact.profileId,
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}", transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
@@ -15,6 +15,8 @@ object FahipayContactParser {
network = TransferNetwork.FAHIPAY, network = TransferNetwork.FAHIPAY,
bankColor = contact.bankColor, bankColor = contact.bankColor,
detail = null, // Fahipay contacts show no detail line detail = null, // Fahipay contacts show no detail line
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
imageHash = contact.customerImgHash, imageHash = contact.customerImgHash,
profileId = contact.profileId, profileId = contact.profileId,
transferSubtitle = contact.benefAccount, transferSubtitle = contact.benefAccount,
@@ -21,6 +21,8 @@ object MibContactParser {
network = network, network = network,
bankColor = contact.bankColor, bankColor = contact.bankColor,
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}", detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
imageHash = contact.customerImgHash, imageHash = contact.customerImgHash,
profileId = contact.profileId, profileId = contact.profileId,
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}", transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M11.8,10.9c-2.27,-0.59 -3,-1.2 -3,-2.15 0,-1.09 1.01,-1.85 2.7,-1.85 1.78,0 2.44,0.85 2.5,2.1h2.21c-0.07,-1.72 -1.12,-3.3 -3.21,-3.81V3h-3v2.16c-1.94,0.42 -3.5,1.68 -3.5,3.61 0,2.31 1.91,3.46 4.7,4.13 2.5,0.6 3,1.48 3,2.41 0,0.69 -0.49,1.79 -2.7,1.79 -2.06,0 -2.87,-0.92 -2.98,-2.1h-2.2c0.12,2.19 1.76,3.42 3.68,3.83V21h3v-2.15c1.95,-0.37 3.5,-1.5 3.5,-3.55 0,-2.84 -2.43,-3.61 -4.7,-4.2z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M3,11h8V3H3V11zM5,5h4v4H5V5zM3,21h8v-8H3V21zM5,15h4v4H5V15zM13,3v8h8V3H13zM19,9h-4V5h4V9zM19,19h2v2h-2V19zM13,13h2v2h-2V13zM15,15h2v2h-2V15zM13,17h2v2h-2V17zM15,19h2v2h-2V19zM17,17h2v2h-2V17zM17,13h2v2h-2V13zM19,15h2v2h-2V15z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M4,7h16v2H4V7zM4,11h16v2H4v-2zM4,15h16v2H4v-2z"/>
</vector>
@@ -175,6 +175,32 @@
</LinearLayout> </LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:orientation="vertical"
android:gravity="end"
android:alpha="0.7">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Next"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
<TextView
android:id="@+id/tvNextOtpCode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSecondaryContainer"
android:letterSpacing="0.1"
android:fontFamily="monospace" />
</LinearLayout>
<com.google.android.material.progressindicator.CircularProgressIndicator <com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/otpTimer" android:id="@+id/otpTimer"
android:layout_width="32dp" android:layout_width="32dp"
+33 -4
View File
@@ -1,15 +1,44 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView" android:id="@+id/recyclerView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingTop="16dp" android:paddingTop="16dp"
android:paddingBottom="16dp"
android:clipToPadding="false" /> android:clipToPadding="false" />
</LinearLayout> <LinearLayout
android:id="@+id/emptyState"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<ImageView
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_marginBottom="12dp"
android:alpha="0.6"
android:importantForAccessibility="no"
android:src="@drawable/ic_nav_otp"
android:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="No OTP codes yet.\nAdd an MIB or BML login to see its codes here."
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</FrameLayout>
@@ -95,6 +95,7 @@
<!-- Include phone number toggle --> <!-- Include phone number toggle -->
<LinearLayout <LinearLayout
android:id="@+id/layoutIncludePhone"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
@@ -9,7 +9,8 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:padding="16dp"> android:padding="16dp"
android:clipToPadding="false">
<TextView <TextView
android:id="@+id/tvLoginsTitle" android:id="@+id/tvLoginsTitle"
@@ -20,10 +21,12 @@
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
android:visibility="gone" /> android:visibility="gone" />
<!-- Rows pad back the negative margin so the tap/drag highlight has a gap around the content -->
<LinearLayout <LinearLayout
android:id="@+id/loginsContainer" android:id="@+id/loginsContainer"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="-12dp"
android:orientation="vertical" /> android:orientation="vertical" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
@@ -148,12 +148,16 @@
app:endIconDrawable="@android:drawable/ic_menu_search" app:endIconDrawable="@android:drawable/ic_menu_search"
app:endIconContentDescription="@string/transfer_lookup_account"> app:endIconContentDescription="@string/transfer_lookup_account">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.MaterialAutoCompleteTextView
android:id="@+id/etTo" android:id="@+id/etTo"
style="@style/Widget.Material3.AutoCompleteTextView.OutlinedBox"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="textNoSuggestions" android:inputType="textNoSuggestions"
android:maxLines="1" /> android:maxLines="1"
android:completionThreshold="3"
android:dropDownHeight="wrap_content"
android:imeOptions="actionSearch" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
+1 -19
View File
@@ -4,6 +4,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:paddingHorizontal="16dp" android:paddingHorizontal="16dp"
android:paddingVertical="10dp"> android:paddingVertical="10dp">
@@ -26,25 +27,6 @@
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"> app:layout_constraintBottom_toBottomOf="parent">
<ImageButton
android:id="@+id/btnEditContact"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_edit"
android:padding="6dp"
android:contentDescription="@string/contact_edit" />
<ImageButton
android:id="@+id/btnDeleteContact"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_delete"
android:padding="6dp"
android:tint="?attr/colorError"
android:contentDescription="@string/contact_delete" />
<ImageButton <ImageButton
android:id="@+id/btnTransferContact" android:id="@+id/btnTransferContact"
android:layout_width="36dp" android:layout_width="36dp"
+141 -33
View File
@@ -2,55 +2,163 @@
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
style="@style/Widget.Material3.CardView.Filled"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp" android:layout_marginHorizontal="16dp"
android:layout_marginBottom="12dp" android:layout_marginBottom="12dp"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
app:cardCornerRadius="16dp" app:cardCornerRadius="24dp">
app:cardElevation="2dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:padding="20dp"> android:paddingHorizontal="20dp"
android:paddingTop="16dp"
android:paddingBottom="12dp">
<TextView <!-- Header: logo, bank + holder name, countdown ring -->
android:id="@+id/tvOtpLabel" <LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/tvOtpCode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceDisplaySmall"
android:textColor="?attr/colorPrimary"
android:fontFamily="monospace"
android:letterSpacing="0.15"
android:layout_marginBottom="16dp" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/otpProgress"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:max="30" android:orientation="horizontal"
app:trackCornerRadius="4dp" android:gravity="center_vertical">
app:indicatorColor="?attr/colorPrimary"
app:trackColor="?attr/colorSurfaceVariant" />
<TextView <com.google.android.material.imageview.ShapeableImageView
android:id="@+id/tvOtpCountdown" android:id="@+id/ivBankLogo"
android:layout_width="wrap_content" android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="12dp"
android:scaleType="fitCenter"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvOtpBank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvOtpLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<FrameLayout
android:layout_width="40dp"
android:layout_height="40dp">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/otpProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="false"
android:max="30"
app:indicatorSize="40dp"
app:trackThickness="3dp"
app:trackCornerRadius="2dp"
app:indicatorColor="?attr/colorPrimary"
app:trackColor="?attr/colorOutlineVariant" />
<TextView
android:id="@+id/tvOtpCountdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
</FrameLayout>
</LinearLayout>
<!-- Current code + copy -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_marginTop="12dp"
android:textAppearance="?attr/textAppearanceLabelSmall" android:orientation="horizontal"
android:textColor="?attr/colorOnSurfaceVariant" /> android:gravity="center_vertical">
<TextView
android:id="@+id/tvOtpCode"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:fontFamily="monospace"
android:letterSpacing="0.12"
android:textAppearance="?attr/textAppearanceDisplaySmall"
android:textColor="?attr/colorPrimary"
android:textStyle="bold" />
<Button
android:id="@+id/btnCopyOtp"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="Copy OTP"
android:tooltipText="Copy OTP"
app:icon="@drawable/ic_copy" />
</LinearLayout>
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp" />
<!-- Next code + copy -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="Next"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvNextOtpCode"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:fontFamily="monospace"
android:letterSpacing="0.1"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<Button
android:id="@+id/btnCopyNextOtp"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="Copy next OTP"
android:tooltipText="Copy next OTP"
app:icon="@drawable/ic_copy"
app:iconTint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout> </LinearLayout>
@@ -0,0 +1,448 @@
<?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:paddingBottom="24dp">
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivPhoto"
android:layout_width="112dp"
android:layout_height="112dp"
android:layout_gravity="center_horizontal"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
<TextView
android:id="@+id/tvName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center_horizontal"
android:paddingHorizontal="24dp"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorOnSurface" />
<LinearLayout
android:id="@+id/actionRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:orientation="horizontal"
android:paddingHorizontal="16dp">
<LinearLayout
android:id="@+id/actionTransfer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_horizontal"
android:orientation="vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnTransfer"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="56dp"
android:layout_height="56dp"
android:contentDescription="@string/transfer"
app:icon="@drawable/ic_send"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:padding="0dp"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:text="@string/transfer"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<LinearLayout
android:id="@+id/actionQr"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_horizontal"
android:orientation="vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnQr"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="56dp"
android:layout_height="56dp"
android:contentDescription="@string/contact_qr"
app:icon="@drawable/ic_qr_code"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:padding="0dp"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp" />
<TextView
android:id="@+id/tvQrLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:text="@string/contact_qr"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<LinearLayout
android:id="@+id/actionEdit"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_horizontal"
android:orientation="vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnEdit"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="56dp"
android:layout_height="56dp"
android:contentDescription="@string/contact_edit"
app:icon="@drawable/ic_edit"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:padding="0dp"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:text="@string/contact_edit"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<LinearLayout
android:id="@+id/actionDelete"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_horizontal"
android:orientation="vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDelete"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="56dp"
android:layout_height="56dp"
android:contentDescription="@string/contact_delete"
app:backgroundTint="?attr/colorErrorContainer"
app:icon="@drawable/ic_delete"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:padding="0dp"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp"
app:iconTint="?attr/colorOnErrorContainer" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:text="@string/contact_delete"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorError" />
</LinearLayout>
</LinearLayout>
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginBottom="8dp" />
<LinearLayout
android:id="@+id/rowAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="64dp"
android:orientation="horizontal"
android:paddingHorizontal="24dp"
android:paddingVertical="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_manage_card"
app:tint="?attr/colorOnSurfaceVariant" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:textIsSelectable="false" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/contact_account_number"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<ImageButton
android:id="@+id/btnShareAccount"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/contact_share_account"
android:padding="8dp"
android:src="@drawable/ic_share"
app:tint="?attr/colorOnSurfaceVariant" />
<ImageButton
android:id="@+id/btnCopyAccount"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/contact_copy_account"
android:padding="8dp"
android:src="@drawable/ic_copy"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<LinearLayout
android:id="@+id/rowRealName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="64dp"
android:orientation="horizontal"
android:paddingHorizontal="24dp"
android:paddingVertical="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_contacts"
app:tint="?attr/colorOnSurfaceVariant" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvRealName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:textIsSelectable="false" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/contact_account_name"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/rowCurrency"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="64dp"
android:orientation="horizontal"
android:paddingHorizontal="24dp"
android:paddingVertical="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_currency"
app:tint="?attr/colorOnSurfaceVariant" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvCurrency"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:textIsSelectable="false" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/contact_currency"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/rowBank"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="64dp"
android:orientation="horizontal"
android:paddingHorizontal="24dp"
android:paddingVertical="8dp">
<ImageView
android:id="@+id/ivBankIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_nav_finances"
app:tint="?attr/colorOnSurfaceVariant" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvBank"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:textIsSelectable="false" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/contact_bank"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/sourceSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="56dp"
android:orientation="horizontal"
android:paddingHorizontal="24dp"
android:paddingVertical="8dp">
<ImageView
android:id="@+id/ivSourceIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvSource"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvSourceBank"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
+1
View File
@@ -145,4 +145,5 @@
<!-- Connectivity banner --> <!-- Connectivity banner -->
<string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string> <string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string>
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string> <string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string>
<string name="drag_to_reorder">ދަމާ ތަރުތީބު ބަދަލުކުރޭ</string>
</resources> </resources>
+9
View File
@@ -332,6 +332,14 @@
<string name="contact_delete_message">Remove %s from your contacts?</string> <string name="contact_delete_message">Remove %s from your contacts?</string>
<string name="contact_deleted">Contact deleted</string> <string name="contact_deleted">Contact deleted</string>
<string name="contact_delete_failed">Could not delete contact</string> <string name="contact_delete_failed">Could not delete contact</string>
<string name="contact_account_number">Account number</string>
<string name="contact_account_name">Account name</string>
<string name="contact_bank">Bank</string>
<string name="contact_qr">QR</string>
<string name="contact_delete_warning">This can\'t be undone.</string>
<string name="contact_copy_account">Copy account number</string>
<string name="contact_share_account">Share account details</string>
<string name="contact_account_copied">Account number copied</string>
<!-- Financing --> <!-- Financing -->
<string name="financing_empty">No financing deals found</string> <string name="financing_empty">No financing deals found</string>
@@ -391,4 +399,5 @@
<!-- Connectivity banner --> <!-- Connectivity banner -->
<string name="connectivity_no_internet">Please check your connection and reload Thijooree</string> <string name="connectivity_no_internet">Please check your connection and reload Thijooree</string>
<string name="connectivity_server_error">Connectivity issue with %s</string> <string name="connectivity_server_error">Connectivity issue with %s</string>
<string name="drag_to_reorder">Drag to reorder</string>
</resources> </resources>
+10
View File
@@ -1,6 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<style name="ShapeAppearance.Circle" parent="ShapeAppearance.Material3.Corner.Full" /> <style name="ShapeAppearance.Circle" parent="ShapeAppearance.Material3.Corner.Full" />
<!-- Destructive confirmation dialog: centered icon/title, filled red confirm button -->
<style name="ThemeOverlay.BasedBank.DestructiveDialog" parent="ThemeOverlay.Material3.MaterialAlertDialog.Centered">
<item name="buttonBarPositiveButtonStyle">@style/Widget.BasedBank.DestructiveDialogButton</item>
</style>
<style name="Widget.BasedBank.DestructiveDialogButton" parent="Widget.Material3.Button">
<item name="backgroundTint">?attr/colorError</item>
<item name="android:textColor">?attr/colorOnError</item>
</style>
<style name="Theme.BasedBank" parent="Theme.Material3.DayNight.NoActionBar"> <style name="Theme.BasedBank" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/seed_primary</item> <item name="colorPrimary">@color/seed_primary</item>
<item name="colorSecondary">@color/seed_secondary</item> <item name="colorSecondary">@color/seed_secondary</item>
+31 -1
View File
@@ -33,6 +33,16 @@ TLV path: **root tag `35` → sub-tag `20` → sub-sub-tag `01`**
The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL. The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL.
### 3. POS QR (`mv.com.bml.qtr`)
BML POS terminals emit an EMVCo-style dynamic QR with no tag `26` and supplementary domain
`mv.com.bml.qtr`. The same TLV path (`35` → `20` → `01`) holds a bare reference such as
`02:215c7b9f15ce4ed28e15697ea976db99` instead of a URL. That bare reference — not the whole payload,
which is rejected with code `112` — is what gets Base64-encoded into the payrequest lookup below.
The reference does **not** resolve as a `pay.bml.com.mv/app/` short code in a browser; only the API
understands it. See
[PayMV QR Format → BML POS QR](../thijooree/18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
--- ---
## PayMV QR Format (TLV) ## PayMV QR Format (TLV)
@@ -71,19 +81,29 @@ PayMV QRs (static, PayMV-native) use a decimal TLV encoding (not BER-TLV):
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url} GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url}
``` ```
`{base64Url}` is the full QR URL (e.g. `https://pay.bml.com.mv/app/...`) base64-encoded with standard encoding (with padding). `{base64Url}` is the lookup key, base64-encoded with standard encoding — the full QR URL
(e.g. `https://pay.bml.com.mv/app/...`), or for POS QRs the bare `35` → `20` → `01` reference.
BML's own app omits the `=` padding; the padded form resolves as well, so the client matches the app
rather than relying on either being required.
### Headers ### Headers
| Header | Value | | Header | Value |
|---|---| |---|---|
| `accept` | `application/json` — **required in practice**, see below |
| `Authorization` | `Bearer <access_token>` | | `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` | | `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` | | `x-app-version` | `2.1.44.348` |
> **`accept: application/json` is not optional.** Handled errors (codes 103, 112, …) come back as
> JSON regardless, but when the route throws, the server renders the Internet Banking HTML login
> page — under HTTP **200** — instead of a JSON error body. A client without the header then sees a
> "successful" HTML response it cannot parse. All BML API requests set it in `bmlApiRequest()`.
```bash ```bash
curl --request GET \ curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \ --url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \
--header 'accept: application/json' \
--header 'Authorization: Bearer <access_token>' \ --header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \ --header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348' --header 'x-app-version: 2.1.44.348'
@@ -116,6 +136,16 @@ curl --request GET \
| `amount` | Payment amount (`"0.00"` for static QRS) | | `amount` | Payment amount (`"0.00"` for static QRS) |
| `currency` | Currency code (typically `"MVR"`) | | `currency` | Currency code (typically `"MVR"`) |
### Failure Responses
`success: false` comes back with a code and a user-facing message; the client shows BML's own
wording and keeps the user on the Transfer screen.
| Code | Message |
|---|---|
| `103` | The payment request has expired |
| `112` | Unsupported payment link |
--- ---
## Step 2 — Pay (3-Step TOTP Flow) ## Step 2 — Pay (3-Step TOTP Flow)
+9
View File
@@ -155,6 +155,15 @@ When the timeout expires a 10-second countdown warning dialog appears. If dismis
Each stored profile has a visibility flag. Hidden profiles are excluded from the accounts list and from all API refresh cycles until re-enabled in Settings → Logins. Each stored profile has a visibility flag. Hidden profiles are excluded from the accounts list and from all API refresh cycles until re-enabled in Settings → Logins.
### Login Order
The user sets the order of their logins in [Settings → Logins](14-settings.md#login-order). It applies everywhere accounts are listed:
- `HomeViewModel.accounts` and `HomeViewModel.mibCards` sort themselves by `CredentialStore.loginRank()` on every `setValue` / `postValue`, so every observer (accounts list, transfer dropdown, contact picker "My accounts", PayMV QR, BML QR pay, transfer history, BML loans in Financing) gets the same order with no per-screen sorting. The sort is stable, so accounts from the same login keep the order the bank returned them in.
- Combined MIB + BML card lists (dashboard stack, [Cards](22-cards.md)) are sorted by `CardItem.loginTag`.
- [Financing](13-financing.md) places the MIB and BML sections by whichever bank's first login ranks higher.
- The [OTP Screen](10-otp-screen.md) sorts its entries by login.
--- ---
## MIB Session KeepAlive ## MIB Session KeepAlive
+12 -9
View File
@@ -13,10 +13,13 @@ Hosts one card per enrolled bank authenticator. Banks with no stored TOTP seed a
## TOTP Display ## TOTP Display
Each card shows: Each card shows:
- Bank logo and name - Bank logo, bank name and account holder name
- The current 6-digit TOTP code (large text) - A circular countdown ring with the seconds left in the current 30-second window; the ring and code turn red in the last 5 seconds
- A circular countdown ring showing time remaining in the current 30-second window - The current 6-digit TOTP code (large text) with a copy button
- The code refreshes automatically when the window expires — no user interaction needed - The next window's code (smaller, below a divider) with its own copy button — handy when the current code is about to expire
- The codes refresh automatically when the window expires — no user interaction needed
Tapping anywhere on the card also copies the current code. If no logins have a seed, an empty-state message is shown instead.
### Algorithm ### Algorithm
@@ -30,14 +33,14 @@ Standard RFC 6238 TOTP:
## Supported Banks ## Supported Banks
One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt:93-98`). Seeds are per-`loginId` in `CredentialStore`. One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt`), sorted by the user's [login order](00-app-overview.md#login-order). Seeds are per-`loginId` in `CredentialStore`.
| Bank | Seed source | Card label | | Bank | Seed source | Card title / subtitle |
|---|---|---| |---|---|---|
| MIB | `loadMibCredentials(loginId).otpSeed` (entered at login) | `"MIB · {fullName}"` | | MIB | `loadMibCredentials(loginId).otpSeed` (entered at login) | `MIB` / `{fullName}` |
| BML | `loadBmlCredentials(loginId).otpSeed` (entered at login) | `"BML · {fullName}"` | | BML | `loadBmlCredentials(loginId).otpSeed` (entered at login) | `BML` / `{fullName}` |
If no full name has been cached the label falls back to plain `"MIB"` / `"BML"` and a background `MibProfileClient.fetchPersonalProfile()` / `BmlAccountClient.fetchUserInfo()` call refreshes it. If no full name has been cached the subtitle falls back to `"Authenticator"` and a background `MibProfileClient.fetchPersonalProfile()` / `BmlAccountClient.fetchUserInfo()` call refreshes it.
--- ---
+3 -1
View File
@@ -2,7 +2,9 @@
> **This flow no longer lives in a dedicated fragment.** `BmlQrPayFragment.kt` still exists as a source file but is unreachable — no callers, no nav graph entry, no intent action routes here. The actual BML gateway QR flow runs inside `TransferFragment` via `TransferFragment.newInstanceFromBmlQr(qrUrl, fromAccountNumber)`. See [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md). > **This flow no longer lives in a dedicated fragment.** `BmlQrPayFragment.kt` still exists as a source file but is unreachable — no callers, no nav graph entry, no intent action routes here. The actual BML gateway QR flow runs inside `TransferFragment` via `TransferFragment.newInstanceFromBmlQr(qrUrl, fromAccountNumber)`. See [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md).
The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve` → `channel: token` → `otp`). The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve` → `channel: token` → `otp`), and [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md#bml-qr-merchant-payment-flow) for the three QR sub-modes the live path handles.
Two differences remain in the stale fragment, should it ever be revived: it calls `lookupPayRequest()` with the scanned URL only (no `PaymvQrParser.bmlPayRequestKey()`, so POS QRs would not resolve), and it swallows every lookup error into the hardcoded `bml_qr_lookup_failed` toast instead of surfacing BML's own message.
--- ---
+2
View File
@@ -8,6 +8,8 @@ Aggregates financing products across banks — MIB promotional deals and BML loa
Observes `HomeViewModel.financing` (MIB deals) and `HomeViewModel.bmlLoanDetails`. Observes `HomeViewModel.financing` (MIB deals) and `HomeViewModel.bmlLoanDetails`.
MIB deals carry no login tag, so the two sections are ordered as blocks: MIB first if the first MIB login ranks above the first BML login in the user's [login order](00-app-overview.md#login-order), otherwise BML first. BML loans within their section follow `HomeViewModel.accounts`, which is already in login order.
--- ---
## MIB Deals Section ## MIB Deals Section
+14
View File
@@ -33,6 +33,20 @@ Each profile entry shows:
- Profile image (circular avatar) - Profile image (circular avatar)
- Visibility toggle switch - Visibility toggle switch
### Login Order
All logins are shown as one list, across banks. When there is more than one login, each row gets a three-line handle (`ic_reorder_lines`) on the right; press and drag it to move the login up or down (`SettingsLoginsFragment.attachDragHandle()`). Tapping the rest of the row still opens the login details.
On drop the order is saved with `CredentialStore.setLoginOrder()`, which:
- writes the combined order to the `login_order` pref as a JSON array of login tags (`mib_<id>`, `bml_<id>`, `fahipay_<id>`, `mfaisa_<id>` — the same format as `BankAccount.loginTag`)
- rewrites each bank's own list (`mib_login_ids`, `bml_login_ids`, …) in the same relative order, so code that walks one bank's logins stays consistent
It then calls `HomeViewModel.resortByLoginOrder()` so already-open screens re-sort immediately.
`CredentialStore.getLoginOrder()` returns the effective order: saved tags that still exist, followed by any logins not yet ordered (e.g. newly added) in the default MIB → BML → Fahipay → M-Faisa order. `loginRank()` turns that into a sort key; unknown tags sort last.
See [Login Order](00-app-overview.md#login-order) for where the order is applied.
### Visibility Toggle ### Visibility Toggle
Toggling a profile off hides it from the accounts list and excludes it from API refresh cycles. The session is kept alive — the profile can be re-enabled without re-logging in. Toggling a profile off hides it from the accounts list and excludes it from API refresh cycles. The session is kept alive — the profile can be re-enabled without re-logging in.
+63 -1
View File
@@ -28,12 +28,13 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
| `00` | Format indicator | Always `"01"` | | `00` | Format indicator | Always `"01"` |
| `01` | Point-of-initiation method | `"11"` = static QR, `"12"` = dynamic QR | | `01` | Point-of-initiation method | `"11"` = static QR, `"12"` = dynamic QR |
| `26` | Merchant account information | Container — see sub-tags below | | `26` | Merchant account information | Container — see sub-tags below |
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs only | | `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs and in BML POS QRs |
| `52` | Merchant category code | `"0000"` (generic) | | `52` | Merchant category code | `"0000"` (generic) |
| `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) | | `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) |
| `54` | Transaction amount | Decimal string (e.g. `"1.50"`); absent for open-amount QRs | | `54` | Transaction amount | Decimal string (e.g. `"1.50"`); absent for open-amount QRs |
| `58` | Country code | `"MV"` | | `58` | Country code | `"MV"` |
| `59` | Merchant / recipient name | Max 25 characters | | `59` | Merchant / recipient name | Max 25 characters |
| `60` | Merchant city / store code | BML POS QRs only |
| `62` | Additional data field | Container — see sub-tags below | | `62` | Additional data field | Container — see sub-tags below |
| `63` | CRC | `6304` prefix + 4-char hex checksum — always last | | `63` | CRC | `6304` prefix + 4-char hex checksum — always last |
| `80` | Supplementary data | Container — timestamp and domain | | `80` | Supplementary data | Container — timestamp and domain |
@@ -153,6 +154,67 @@ The value at sub-sub-tag `01` is a full `https://pay.bml.com.mv/app/...` URL. Ex
Plain BML QR codes (not combined) start with `https://pay.bml.com.mv/app/` directly. Plain BML QR codes (not combined) start with `https://pay.bml.com.mv/app/` directly.
`PaymvQrParser.bmlQrPayTarget()` covers all of these: it returns the URL for plain URL QRs and for
combined QRs, the whole EMV payload for POS QRs (below, recognised by the `mv.com.bml.qtr`
supplementary domain), and null for everything else — PayMV QRs keep falling through to `parse()`.
---
## BML POS QR (`mv.com.bml.qtr`)
BML POS terminals emit a third shape — an EMVCo-style dynamic QR whose supplementary data domain
(tag `80` → `00`) is `mv.com.bml.qtr` and which has **no tag `26`**, so there is no PayMV account
number to transfer to. The same `35` → `20` container is used as in combined QRs, but sub-tag `01`
holds a bare reference instead of a URL.
Example (CRC verified, same CRC-16/CCITT-FALSE as above):
```
00020101021235752071000202013502:215c7b9f15ce4ed28e15697ea976db9902109809724081030874009538520400005303462540436005802MV5911BEST BANANA6006LD044262220510dtyams497d0804POPE80470014mv.com.bml.qtr01252026-09-21T13:33:22.00000630443FA
```
| TLV path | Value | Notes |
|---|---|---|
| `00` | `01` | Format indicator |
| `01` | `12` | Dynamic QR |
| `35`→`20`→`00` | `02` | Version / format of the container (combined QRs use the URL form) |
| `35`→`20`→`01` | `02:215c7b9f15ce4ed28e15697ea976db99` | Payment reference — `<type>:<32 hex>` |
| `35`→`20`→`02` | `9809724081` | Merchant identifier (10 digits) |
| `35`→`20`→`03` | `74009538` | Terminal identifier (8 digits) |
| `52` | `0000` | MCC |
| `53` | `462` | MVR |
| `54` | `3600` | Amount — **no decimal point**, unlike PayMV's `"1.50"` |
| `58` / `59` / `60` | `MV` / `BEST BANANA` / `LD0442` | Country, merchant name, merchant city/store code |
| `62`→`05` | `dtyams497d` | Reference / bill number |
| `62`→`08` | `POPE` | Purpose / terminal label |
| `80`→`00` | `mv.com.bml.qtr` | Domain — identifies the POS format |
| `80`→`01` | `2026-09-21T13:33:22.00000` | Timestamp |
### Pay-Request Lookup Key
The lookup key is the **bare reference**, Base64-encoded without padding:
```
GET .../walletpayments/payrequest/MDI6MjE1YzdiOWYxNWNlNGVkMjhlMTU2OTdlYTk3NmRiOTk
```
BML's own app omits the `=` padding, so `BmlQrPayClient.lookupPayRequest()` encodes with
`NO_WRAP or NO_PADDING` for every QR type; the padded form resolves too. What the request *must*
carry is `accept: application/json` — see
[QR Payment → Step 1 headers](../bmlapi/13-qr-payment.md#headers).
Confirmed against the live API (the example QR had already expired, so BML answered `103` rather
than with merchant details — but `103` means the reference itself resolved):
| Key tried | Response |
|---|---|
| `02:215c7b9f15ce4ed28e15697ea976db99` | `103` — "The payment request has expired" ✅ recognised |
| the whole EMV payload | `112` — "Unsupported payment link" ❌ |
| `https://pay.bml.com.mv/app/02:215c…` | `103` — recognised too; the host itself 400s on that path, so the backend must strip the prefix |
`PaymvQrParser.bmlPayRequestKey()` returns the URL for URL QRs and the reference for POS QRs, so
exactly one request is made either way.
--- ---
## Example Payload ## Example Payload
+15 -8
View File
@@ -13,7 +13,7 @@ The transfer screen (`TransferFragment`) handles all outgoing payments across MI
| `newInstance(accountNumber, displayName, subtitle, colorHex, imageHash)` | Pre-fills the "To" card from a contact, recents pick, or About → Donate | | `newInstance(accountNumber, displayName, subtitle, colorHex, imageHash)` | Pre-fills the "To" card from a contact, recents pick, or About → Donate |
| `newInstanceFrom(account: BankAccount)` | Pre-selects the given account in the "From" dropdown | | `newInstanceFrom(account: BankAccount)` | Pre-selects the given account in the "From" dropdown |
| `newInstanceFromQr(accountNumber, displayName, amount, remarks, fromAccountNumber?)` | Pre-fills recipient + optional amount/remarks from a PayMV QR scan | | `newInstanceFromQr(accountNumber, displayName, amount, remarks, fromAccountNumber?)` | Pre-fills recipient + optional amount/remarks from a PayMV QR scan |
| `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway QR merchant payment mode — locks recipient, may pre-fill amount | | `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway/POS QR merchant payment mode — locks recipient, may pre-fill amount |
| `newInstanceWithAutoScan()` | Opens the [QR scanner](25-qr-scanner.md) immediately on load | | `newInstanceWithAutoScan()` | Opens the [QR scanner](25-qr-scanner.md) immediately on load |
--- ---
@@ -215,22 +215,29 @@ If channel fetch fails or returns empty, the flow is aborted and the form is re-
## BML QR Merchant Payment Flow ## BML QR Merchant Payment Flow
Triggered when the transfer screen is opened via `newInstanceFromBmlQr()` or when a BML ebanking/pay.bml URL is scanned from the QR scanner. Triggered when the transfer screen is opened via `newInstanceFromBmlQr()`, which every scanner caller reaches through `PaymvQrParser.bmlQrPayTarget(raw)` — it returns the value to pay with, or null for a QR that is not BML's.
Two sub-modes: Three sub-modes:
| Mode | Trigger | Extra step | | Mode | `bmlQrPayTarget()` returns | Extra step |
|---|---|---| |---|---|---|
| Static card QR | URL starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None | | Static card QR | the QR text, when it starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None |
| Gateway QR | URL starts with `https://pay.bml.com.mv/app/` | `BmlQrPayClient.preInitiatePayment()` required before initiate | | Gateway QR | the QR text, or the URL at TLV `35`→`20`→`01` in a combined EMV QR | `BmlQrPayClient.preInitiatePayment()` required before initiate |
| POS QR | the whole EMV payload, for QRs whose tag `80`→`00` domain is `mv.com.bml.qtr` | Treated as a gateway QR — see the note below |
`BmlTransferHandler.lookupQrMerchant()` passes that value through `PaymvQrParser.bmlPayRequestKey()`, which hands the URL to the lookup for URL QRs and the bare `35`→`20`→`01` reference for POS QRs. See [PayMV QR Format — BML POS QR](18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
Flow: Flow:
1. `lookupBmlQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row. 1. `lookupQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row.
2. For dynamic QRs (`info.amount > 0`), pre-fills the amount and locks the amount field. 2. For dynamic QRs (`info.amount > 0`), pre-fills the amount and locks the amount field.
3. Remarks field is locked (not applicable for merchant payments). 3. Remarks field is locked (not applicable for merchant payments).
4. On confirm: TOTP is generated, then `initiatePayment()` → (for gateway QR: `preInitiatePayment()` first) → `confirmPayment()` with a fresh TOTP. 4. On confirm: TOTP is generated, then `initiatePayment()` → (for gateway QR: `preInitiatePayment()` first) → `confirmPayment()` with a fresh TOTP.
5. On success: a success dialog is shown (no receipt saved). Back-press returns to previous screen. 5. On success: a success dialog is shown (no receipt saved). Back-press returns to previous screen.
**Lookup failure:** the user stays on the Transfer screen with the "To" row restored via `resetToFieldVisibility()` — the screen is no longer popped. When BML answered with `success: false`, its own wording is toasted (`BmlQrPayLookupException.message`, e.g. "The payment request has expired"); network, empty and non-JSON responses fall back to the `bml_qr_lookup_failed` string.
> **Unverified:** POS QRs are treated as gateway QRs (pre-initiate before initiate) because they carry a preset amount. No POS payment has been completed end-to-end yet — the reference captured for testing had already expired.
--- ---
## Transfer Button Enable Conditions ## Transfer Button Enable Conditions
@@ -238,7 +245,7 @@ Flow:
The transfer button is only enabled when all of the following are true: The transfer button is only enabled when all of the following are true:
- A source account is selected - A source account is selected
- A recipient is resolved (`resolvedAccountNumber` not blank, or `bmlQrInfo` is set) - A recipient is resolved (`resolvedAccountNumber` not blank, or the BML handler's `qrInfo` is set)
- Amount is greater than `0` - Amount is greater than `0`
- No connectivity error for `NO_INTERNET` or for the source bank - No connectivity error for `NO_INTERNET` or for the source bank
+1
View File
@@ -29,6 +29,7 @@ A horizontal `RecyclerView` with `LinearSnapHelper` that paginates BML cards + M
- BML cards: visible accounts with `profileType` in (`BML_PREPAID`, `BML_CREDIT`, `BML_DEBIT`) AND `statusDesc == "Active"` - BML cards: visible accounts with `profileType` in (`BML_PREPAID`, `BML_CREDIT`, `BML_DEBIT`) AND `statusDesc == "Active"`
- MIB cards: filtered by `CardsFragment.isMibCardActive(cardStatus)` (i.e. `CHST0`) - MIB cards: filtered by `CardsFragment.isMibCardActive(cardStatus)` (i.e. `CHST0`)
- Cards are sorted by the user's [login order](00-app-overview.md#login-order)
- The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to the front - The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to the front
- Cards hidden via `getHiddenDashboardCardNumbers()` are skipped - Cards hidden via `getHiddenDashboardCardNumbers()` are skipped
+1 -1
View File
@@ -29,7 +29,7 @@ A horizontal `RecyclerView` driven by `CardStackAdapter` with a `PagerSnapHelper
- BML: `viewModel.accounts` filtered to `profileType in (BML_PREPAID, BML_CREDIT, BML_DEBIT)` - BML: `viewModel.accounts` filtered to `profileType in (BML_PREPAID, BML_CREDIT, BML_DEBIT)`
- MIB: `viewModel.mibCards` - MIB: `viewModel.mibCards`
The combined order is `bmlActive + mibActive + bmlInactive + mibInactive`. The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to position 0. Initial data is seeded from `CardsCache` so the stack appears immediately; a background `HomeActivity.triggerRefreshCards()` refreshes it. Active cards come first, then inactive ones; within each group cards are sorted by the user's [login order](00-app-overview.md#login-order) via `CardItem.loginTag`. The user's default card (`CredentialStore.getDefaultCardAccountNumber()`) is moved to position 0. Initial data is seeded from `CardsCache` so the stack appears immediately; a background `HomeActivity.triggerRefreshCards()` refreshes it.
### Card Art ### Card Art
+2 -2
View File
@@ -57,10 +57,10 @@ Each caller registers an `ActivityResultContracts.StartActivityForResult` launch
| Caller | Result handling | | Caller | Result handling |
|---|---| |---|---|
| [TransferFragment](07-transfer.md) | Routes PayMV / BML URL via `PaymvQrParser` + `extractBmlGatewayUrl` | | [TransferFragment](07-transfer.md) | `PaymvQrParser.bmlQrPayTarget()` first (URL, combined and POS QRs → BML QR pay), then M-Faisa numeric ids, then `PaymvQrParser.parse()` |
| [PayMvQrFragment](11-paymv-qr-screen.md) | Generation only — does not call the scanner directly | | [PayMvQrFragment](11-paymv-qr-screen.md) | Generation only — does not call the scanner directly |
| `CredentialsFragment` (login) | `OtpauthParser.parse(raw)` → fills `etOtpSeed` or shows a chooser for multi-entry QRs | | `CredentialsFragment` (login) | `OtpauthParser.parse(raw)` → fills `etOtpSeed` or shows a chooser for multi-entry QRs |
| [DashboardFragment](21-dashboard.md) | BML URL → BML QR pay; PayMV → pre-fill Transfer; otherwise toast | | [DashboardFragment](21-dashboard.md) | BML QR (URL or POS) → BML QR pay; PayMV → pre-fill Transfer; otherwise toast |
| [CardsFragment](22-cards.md) | Same routing as Dashboard, scoped to the active BML card | | [CardsFragment](22-cards.md) | Same routing as Dashboard, scoped to the active BML card |
### Share-to-Scan Fast Path ### Share-to-Scan Fast Path
+1 -1
View File
@@ -22,7 +22,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR (send/scan lives in Transfer) | | [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR (send/scan lives in Transfer) |
| [12 — BML QR Pay](12-bml-qr-pay.md) | (Stub — see Transfer Flows for the live BML QR merchant flow) | | [12 — BML QR Pay](12-bml-qr-pay.md) | (Stub — see Transfer Flows for the live BML QR merchant flow) |
| [13 — Financing](13-financing.md) | MIB promotional deals, BML loans, BML foreign spend limits | | [13 — Financing](13-financing.md) | MIB promotional deals, BML loans, BML foreign spend limits |
| [14 — Settings](14-settings.md) | Settings hub: Logins, Appearance, Privacy & Security, Notifications, Storage, About | | [14 — Settings](14-settings.md) | Settings hub: Logins (drag to reorder), Appearance, Privacy & Security, Notifications, Storage, About |
| [15 — Settings: Security](15-settings-security.md) | Change lock method, biometrics, auto-lock timeout, screenshots | | [15 — Settings: Security](15-settings-security.md) | Change lock method, biometrics, auto-lock timeout, screenshots |
| [16 — Settings: Appearance](16-settings-appearance.md) | Navigation mode, slot drag-reorder, theme, accent colour, language | | [16 — Settings: Appearance](16-settings-appearance.md) | Navigation mode, slot drag-reorder, theme, accent colour, language |
| [17 — Settings: Storage](17-settings-storage.md) | Single "Clear All Caches" button | | [17 — Settings: Storage](17-settings-storage.md) | Single "Clear All Caches" button |
@@ -0,0 +1 @@
- Support for BML payment gateway using PayMV QR
@@ -0,0 +1,5 @@
- NFC tap-to-pay with wallet integration
- Set a default card for NFC payments
- Launcher shortcut renamed to "Scan to Pay"
- New NFC icon
- Cards hidden when not relevant
@@ -0,0 +1,9 @@
- Default transfer accounts
- Share-to-scan-to-pay with merchant details
- Static BML QR scans saved to recents
- Unified QR scan flow across banks
- Friendlier transfer flow UI
- Removed *** masking on PayMV QR amounts
- New NFC tap-to-pay animation
- Fixed NFC-related crash
- Scanning a PayMV QR after selecting a card falls back to the transfer flow
@@ -0,0 +1,2 @@
- Fixed transfer form resetting on switching tabs
- Fixed default account not selected from contacts page or contact picker
@@ -0,0 +1 @@
- New navigation mode: Nostalgic
@@ -0,0 +1,10 @@
- View details of blocked balance
- Notifications
- Wheel optimizations
- Fixed back navigation from Finances page
- Fixed "An error occurred" instead of "No available balance"
- Fixed Islamic visa card image
- Fixed PIN button glyph scaling and theme bugs
- Fixed receipt buttons on some phones
- Fixed balance not updating after BML QR payment
- Fixed onboarding warning bypass
@@ -0,0 +1 @@
- NFC prompts for off, default and not supported states
@@ -0,0 +1,3 @@
- About page
- Donate to support development
- Import TOTP QR and Google Authenticator export QR
@@ -0,0 +1,6 @@
- BML and MIB card freeze/unfreeze
- Background service and push notifications (very unstable)
- Cards ordered: active BML, MIB, inactive BML, inactive MIB
- Faster notification loading
- Fixed notification icon in light theme
- Fixed contact picker rendering
@@ -0,0 +1,9 @@
- Edge-to-edge support for Android 15+
- Redesigned onboarding pages and added disclaimer
- otpauth://totp/ URI import for OTP seed
- Hint shown when biometrics are disabled
- App resumes last screen after unlocking
- Loading indicators for account info and transfers
- Fixed bottom bar staying above the keyboard
- Fixed adding a contact with multiple BML logins
- Added Visa, Master and Amex card icons
@@ -0,0 +1,8 @@
- Contact picker recents show most recent first
- Outstanding and unbilled credit card values
- Fixed MIB USD history amounts
- Fixed currency detection in MIB to MIB transfers
- Option to add contact during BML to MIB USD transfer
- Settings item renamed to "Confirm Transaction"
- Fixed "Cards" title in tap to pay from shortcut
- Removed gap under bottom bar on cards screens
@@ -0,0 +1 @@
- Initial support for Ooredoo M-Faisa
@@ -0,0 +1,4 @@
- Updated BML card mapping (fixed Mastercard Platinum)
- Disabled PayMV QR for M-Faisa accounts
- Fixed share and save buttons showing on shared receipt image
- Fixed bottom bar flashing before receipt share sheet
@@ -0,0 +1,5 @@
- Fixed QR scanner failing to launch on revisit
- Fixed PayMV scan keeping unusable M-Faisa source
- Fixed BML merchant staying loaded after payment
- Transfer logic split into per-bank handlers
- Note: Fahipay is still broken
@@ -0,0 +1 @@
- No changes from previous version
@@ -0,0 +1,7 @@
- Hide accounts you don't want to see
- Reorder logins
- Account number field on the transfer page suggests saved contacts
- Transfer page shows the other account's currency
- Search button on the transfer "To" field keyboard, focus moves to amount afterwards
- Search key in the add contact drawer now works
- Saving a profile disable no longer sends unnecessary refresh requests
@@ -0,0 +1,5 @@
- Redesigned OTP page: codes turn red in the last 5 seconds and there are copy buttons, and you can tap a card to copy its code
- OTP page and login setup now also show the next code
- Redesigned contacts list: tap a contact to open a details sheet, which now holds the edit and delete buttons
- Contact details show the account number, account name, bank and currency
- Copy or share a contact's account details, or show a PayMV QR code for them
@@ -0,0 +1,5 @@
- Customizable bottom bar and quick actions
- Multiple Fahipay and MIB account support
- Updated BML logo
- Transfer button stays disabled until all required fields are filled
- Fixed display issues on phones without edge-to-edge screens
@@ -0,0 +1,8 @@
- BML business profile support
- Privacy mode toggle to hide balances
- View previous transfer receipts, full screen view, long-press to copy, save and share
- Initial PayMV QR generation
- Renamed Activities to Recent Transfers
- Fixed card transaction history not loading
- Fixed BML contact list loading
- Fixed single-profile multi-login issue
@@ -0,0 +1,5 @@
- BML loans shown on the Financing page, included in pending finances
- Animated lock and show/hide amount icons
- Credit cards and spending limits in a separate dashboard section, limits shown as progress bars
- Optimized MIB session keepalive
- Long-press to copy receipt fields in full screen
@@ -0,0 +1,8 @@
- MIB debit/credit cards shown in a dashboard carousel
- Transfers from BML business profile accounts
- New setting: auto unlock on correct PIN
- Fewer network requests on BML session refresh
- Refresh indicator moved to the action bar
- Reworked back button navigation
- Fixed PayMV QR page empty space
- BML loan accounts excluded from balance total and transfer sources
@@ -0,0 +1,7 @@
- Compressed MIB card images to reduce app size
- Connectivity banners for network issues
- Transfer button disabled when source bank is unavailable
- Fixed cache read issue when refreshing offline
- Privacy mode hides amounts in transfer dropdown
- User-agent reports real device info
- Fixed error on failed PIN entry
@@ -0,0 +1,8 @@
- QR payments from BML gateway and static QR payments from BML cards
- Zoom and flashlight on QR scanner
- Bank/profile images in accounts list and transfer dropdown
- Local per-profile images for BML and Fahipay
- Better network error handling and timeout handling
- Fixed contacts infinite loading offline
- Fixed crash with no network on transaction history
- Fixed nav bar buttons disappearing
@@ -0,0 +1,10 @@
- Manage cards mode and default payment card
- Theme customizations
- Launcher shortcuts
- Tap to copy OTP
- Unified card settings and Pay with Card page
- Dashboard shows MVR and USD blocked funds separately
- Accounts list uses available balance
- Fixed lockscreen bypass on rooted devices
- Fixed empty dashboard without accounts
- PayMV QR generation fix
@@ -0,0 +1,31 @@
Thijooree is an unofficial, unified banking app for the Maldives, built by reverse engineering the official bank apps.
Supported services:
- Bank of Maldives (BML)
- Maldives Islamic Bank (MIB)
- Fahipay (initial support)
- Ooredoo M-Faisa (initial support)
Features:
- Multiple logins per bank in one app, with a unified dashboard of all your accounts
- Transfers between accounts and banks, with saved contacts and Favara IDs
- Transaction history for accounts
- Cards: view BML and MIB cards, freeze/unfreeze
- NFC tap-to-pay
- QR payments: scan PayMV and BML QR codes, generate your own PayMV QR, with zoom and flashlight on the scanner
- Loans, credit cards, spending limits and blocked funds on the Financing page
- Built-in TOTP: import from otpauth:// URIs, QR codes or Google Authenticator exports
- Notifications
- Privacy mode to hide balances
- PIN and biometric app lock
- Customizable bottom bar, quick actions and themes
- Hide specific accounts per profile
Requirements:
- Your banking credentials for each bank
Privacy:
No data ever leaves your device except the API calls to the banking services themselves.
Disclaimer:
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by BML, MIB, Fahipay or Ooredoo. Use at your own risk. Review the source code before entering your banking credentials.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

@@ -0,0 +1 @@
Unified banking app for Maldives from reverse enginered official bank apps.
@@ -0,0 +1 @@
Thijooree