Compare commits

..
Author SHA1 Message Date
azuwlaaandClaude Opus 5.5 83ca37eade chats: business payments, new bubbles, pinning, Scan to Pay
- Card / Scan to Pay payments show as business chats (from BML
  "Payment Completed" alerts and "Purchase" history), kept apart from
  people; static merchant QRs are kept so a business can be paid again
  from its chat, with a card picker
- Scan to Pay button on the chat list; returns and re-checks after paying
- Compact bubbles with ticks (alert seen / booked in history), receipt
  button, day totals, monthly summary, long-press actions
- Personal accounts confirm with just the dialog over the chat; business
  profiles keep the sheet for the OTP step
- From chip shows account number and balance; MVR -> USD account is
  blocked, USD -> MVR warns about conversion
- Long-press a chat to pin it to the top

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQUrmJjrypeoPsubpzuezC
2026-09-26 04:10:06 +05:00
azuwlaaandClaude Opus 5.5 da5cca79c6 add chats: transfers grouped per person as a chat
Replaces Contacts in the default bottom nav slot with a Chats page
(Contacts moves to More and opens from the Chats toolbar).

- Chats start when the page is first opened; no old history is loaded
- Transfers come from BML notifications (instant), account history
  (booked later) and in-app receipts, merged into one bubble each
- Chats are keyed by the account holder's real name, looked up via
  BML account validation, so nickname contacts and their MVR/USD
  accounts share one chat; the To bar picks the account to send to
- Sending opens the Transfer page in a bottom sheet over the chat and
  returns to it on success; sent bubbles link to their receipt
- Chat and name data are stored encrypted with CacheEncryption

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQUrmJjrypeoPsubpzuezC
2026-09-25 17:29:01 +05:00
115 changed files with 3239 additions and 2370 deletions
+1 -5
View File
@@ -12,16 +12,12 @@ A native Android client for Maldivian banking services. It is a pure client: req
- Android 8.0+ (API 26) - Android 8.0+ (API 26)
- Existing accounts with MIB, BML, or Fahipay - Existing accounts with MIB, BML, or Fahipay
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank. See [how to get your TOTP seed](docs/thijooree/faq/totpseed/README.md) - Your TOTP seed (base32 secret from your authenticator app setup) for each bank
## Download APK ## Download APK
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases) [Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
[Telegram Channel](https://t.me/s/thijooreeapks) [Telegram Channel](https://t.me/s/thijooreeapks)
## FAQ
Common questions and setup guides are in the [FAQ](docs/thijooree/faq/README.md).
## Privacy ## Privacy
No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/thijooree/AI_SECURITY_CHECK.md) for a full list of every server the app connects to. No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/thijooree/AI_SECURITY_CHECK.md) for a full list of every server the app connects to.
+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 = 32 versionCode = 28
versionName = "1.0.31" versionName = "1.0.27"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -1,82 +0,0 @@
package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
/**
* BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`),
* e.g. the bill links Fenaka sends. The web page only shows a QR; this fetches the QR's text so it
* can go through the regular BML QR payment flow.
*/
class BmlMerchantTxnClient {
private val client = newBmlApiClient()
/**
* Returns the transaction's EMV QR payload (`vendorQrCode`).
*
* A GET on the transaction is 401 without the page's Cognito credentials, but the PATCHes the
* page itself sends need no auth and return the full transaction:
* - on load, `activeBrowserId` (`<id>_<epoch millis>`);
* - on picking "BML" as the payment method, `provider: bml_mpos`.
*
* A fresh link has no provider yet, so `vendorQrCode` is null until the second PATCH selects
* one. Links already opened with BML chosen return it from the first.
*/
fun fetchQrPayload(transactionId: String): String {
val browserId = JSONObject()
.put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}")
patch(transactionId, browserId).vendorQrCode()?.let { return it }
// Whether the provider PATCH returns the QR itself or it is generated a moment later has
// not been observed, so re-read a few times before giving up. Re-reads use the load PATCH:
// each provider PATCH counts as another payment attempt.
var txn = patch(transactionId, JSONObject().put("provider", PROVIDER_BML))
repeat(3) {
txn.vendorQrCode()?.let { return it }
Thread.sleep(1000)
txn = patch(transactionId, browserId)
}
return txn.vendorQrCode() ?: throw Exception("Transaction has no QR")
}
private fun patch(transactionId: String, body: JSONObject): JSONObject {
val request = Request.Builder()
.url("$API_BASE/transactions/$transactionId")
.patch(body.toString().toRequestBody("application/json".toMediaType()))
.header("Accept", "*/*")
.header("Origin", PAGE_ORIGIN)
.header("Referer", "$PAGE_ORIGIN/")
.build()
return client.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty()
if (!response.isSuccessful || !text.trimStart().startsWith("{"))
throw Exception("Transaction lookup failed (HTTP ${response.code})")
JSONObject(text)
}
}
/**
* No state check: only QR_CODE_GENERATED has been observed, and BML's payrequest lookup
* already rejects a paid or expired QR with its own message. `isNull` first — `optString`
* turns a JSON null into the string "null".
*/
private fun JSONObject.vendorQrCode(): String? =
if (isNull("vendorQrCode")) null else optString("vendorQrCode").ifBlank { null }
companion object {
private const val API_BASE = "https://api.merchants.bankofmaldives.com.mv"
private const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv"
private const val PROVIDER_BML = "bml_mpos"
private val TXN_URL = Regex("^https?://transaction\\.merchants\\.bankofmaldives\\.com\\.mv/([0-9a-fA-F]{24})(?:[/?#].*)?$")
private val TXN_ID = Regex("^[0-9a-fA-F]{24}$")
/** The transaction ID from a bare 24-hex ID or a pasted payment link, else null. */
fun parseTransactionId(input: String): String? {
val s = input.trim()
val id = if (TXN_ID.matches(s)) s else TXN_URL.find(s)?.groupValues?.get(1)
return id?.lowercase()
}
}
}
@@ -50,11 +50,6 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
} }
.build() .build()
/** Swap the seed used for silent re-login after the user replaces it on the OTP screen. */
fun updateOtpSeed(otpSeed: String) {
if (storedOtpSeed != null) storedOtpSeed = otpSeed
}
// ─── Public entry point ─────────────────────────────────────────────────── // ─── Public entry point ───────────────────────────────────────────────────
/** /**
@@ -0,0 +1,686 @@
package sh.sar.basedbank.ui.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentChatBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ViewChatHeaderBinding
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactListParser
import sh.sar.basedbank.util.ReceiptStore
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.ChatAccount
import sh.sar.basedbank.util.ChatStore
import sh.sar.basedbank.util.ChatThread
/**
* One person's transfers as a chat, or one business's card / Scan to Pay payments.
*
* A person can have several accounts (e.g. MVR and USD); the "To" bar picks which one to send to.
* Sending opens [TransferFragment] in a sheet, prefilled with the recipient, source account,
* amount and note, so the usual confirm/OTP flow still applies. A monthly summary sits on top,
* and long-pressing a bubble offers Send again / receipt / copy.
*/
class ChatFragment : Fragment(), ContactSheetHost {
private var _binding: FragmentChatBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private val app get() = requireActivity().application as BasedBankApp
private var peerKey = ""
private var header: ViewChatHeaderBinding? = null
private val adapter = ChatMessagesAdapter(
onReceiptClick = { openReceipt(it.receiptKey) },
onLongPress = { message, anchor -> showMessageMenu(message, anchor) }
)
private var thread: ChatThread? = null
private var fromAccounts: List<BankAccount> = emptyList()
private var selectedFrom: BankAccount? = null
private var selectedTo: ChatAccount? = null
private var merchantQr: String? = null
private var cardAccounts: List<BankAccount> = emptyList()
private var selectedCard: BankAccount? = null
/** When the last transfer / payment sheet was opened, to tell whether it left a new receipt. */
private var payStartedAt = 0L
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentChatBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
peerKey = savedInstanceState?.getString(ARG_PEER_KEY) ?: requireArguments().getString(ARG_PEER_KEY).orEmpty()
binding.rvMessages.layoutManager = LinearLayoutManager(requireContext()).apply { stackFromEnd = true }
binding.rvMessages.adapter = adapter
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) {
adapter.setHideAmounts(it)
thread?.let { t -> bindSummary(t) }
bindFromChip()
}
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
fromAccounts = accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }
cardAccounts = accounts.filter {
it.bank == "BML" && it.profileType in CARD_TYPES && it.statusDesc.equals("Active", ignoreCase = true)
}
bindFromAccounts()
}
binding.btnFrom.setOnClickListener { if (thread?.isMerchant == true) showCardPicker() else showAccountPicker() }
binding.btnSaveContact.setOnClickListener { openContact() }
binding.toAccountBar.setOnClickListener { showRecipientPicker() }
binding.btnSend.setOnClickListener { send() }
binding.etNote.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEND) { send(); true } else false
}
binding.btnPayAgain.setOnClickListener { payBusiness() }
// Transfers started from this chat come straight back here instead of opening the receipt.
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
onTransferDone()
}
// A newly saved contact adds an account (and maybe a nickname) to this chat; reload.
viewModel.contacts.observe(viewLifecycleOwner) { loadThread() }
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(ARG_PEER_KEY, peerKey)
}
private fun loadThread() {
val ctx = requireContext().applicationContext
val contacts = viewModel.contacts.value ?: emptyList()
viewLifecycleOwner.lifecycleScope.launch {
val threads = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) }
val knownIds = thread?.messages?.mapTo(HashSet()) { it.id }.orEmpty()
val t = threads.firstOrNull { it.peerKey == peerKey }
?: threads.firstOrNull { th -> th.messages.any { it.id in knownIds } }
if (_binding == null) return@launch
thread = t
if (t == null) return@launch
peerKey = t.peerKey
adapter.showDestination = t.accounts.size > 1
adapter.setMessages(t.messages, getString(R.string.chat_today), getString(R.string.chat_yesterday))
binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
bindSummary(t)
bindFromAccounts()
val previous = selectedTo
val to = t.accounts.firstOrNull { it.account == previous?.account }
?: t.accounts.firstOrNull { it.account == t.peerAccount }
selectTo(to, matchFromCurrency = previous == null)
updateToolbar()
}
}
/** Picks which of the person's accounts to send to and updates everything that depends on it. */
private fun selectTo(to: ChatAccount?, matchFromCurrency: Boolean) {
val b = _binding ?: return
val t = thread ?: return
selectedTo = to
if (t.isMerchant) {
// Businesses paid by card / Scan to Pay: history only, nothing to send to or save.
b.toAccountBar.visibility = View.GONE
b.saveContactBar.visibility = View.GONE
b.composer.visibility = View.GONE
b.tvCannotSend.visibility = View.GONE
b.btnPayAgain.visibility = View.VISIBLE
bindMerchantQr(t)
return
}
b.btnPayAgain.visibility = View.GONE
// One account: it's shown in the header. Several: this bar picks between them.
b.toAccountBar.visibility = if (to == null || t.accounts.size < 2) View.GONE else View.VISIBLE
if (to != null) {
b.tvToAccount.text = to.account
b.tvToCurrency.text = to.currency
b.tvToCurrency.visibility = if (to.currency.isBlank()) View.GONE else View.VISIBLE
val nickname = to.contact?.benefNickName?.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) }
b.tvToNickname.text = nickname
b.tvToNickname.visibility = if (nickname == null) View.GONE else View.VISIBLE
b.ivToSwitch.visibility = if (t.accounts.size > 1) View.VISIBLE else View.GONE
}
// Offer to save the selected account (or the person, when no account is known yet).
b.saveContactBar.visibility = if (to?.contact == null) View.VISIBLE else View.GONE
val canSend = to != null
b.composer.visibility = if (canSend) View.VISIBLE else View.GONE
b.tvCannotSend.visibility = if (canSend) View.GONE else View.VISIBLE
// Sending to a USD account: default to a USD source account, and likewise for MVR.
if (matchFromCurrency && to != null && to.currency.isNotBlank() &&
!selectedFrom?.currencyName.equals(to.currency, ignoreCase = true)) {
fromAccounts.firstOrNull { it.currencyName.equals(to.currency, ignoreCase = true) }?.let(::selectFrom)
}
updateCurrencyWarning()
}
private fun showRecipientPicker() {
val t = thread ?: return
if (t.accounts.size < 2) return
val ctx = requireContext()
val dialog = BottomSheetDialog(ctx)
val list = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val pad = (8 * resources.displayMetrics.density).toInt()
setPadding(0, pad, 0, pad * 3)
}
list.addView(sheetTitle(getString(R.string.chat_pick_recipient_account)))
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
for (account in t.accounts) {
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
row.tvDropdownAccountName.text = account.contact?.benefNickName?.takeIf { it.isNotBlank() }
?: getString(R.string.chat_not_in_contacts)
row.tvDropdownAccountNumber.text = account.account
row.tvDropdownBalance.text = account.currency
row.ivDropdownCardLogo.visibility = View.GONE
// A USD account can only be paid from a USD account.
val unavailable = account.currency.equals("USD", ignoreCase = true) && !hasUsdSource()
row.tvDropdownAccountType.text = getString(R.string.chat_needs_usd_source)
row.tvDropdownAccountType.visibility = if (unavailable) View.VISIBLE else View.GONE
row.root.alpha = if (unavailable) 0.4f else 1f
if (account.account == selectedTo?.account) row.root.setBackgroundColor(selectedBg)
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
if (!unavailable) row.root.setOnClickListener {
selectTo(account, matchFromCurrency = true)
updateToolbar()
dialog.dismiss()
}
list.addView(row.root)
}
dialog.setContentView(list)
dialog.show()
}
private fun sheetTitle(text: String) = TextView(requireContext()).apply {
this.text = text
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_TitleMedium)
val pad = (16 * resources.displayMetrics.density).toInt()
setPadding(pad, pad / 2, pad, pad / 2)
}
private fun bindFromAccounts() {
val b = _binding ?: return
if (fromAccounts.isEmpty()) return
// Default to the account last used with this person.
val lastUsed = thread?.messages?.lastOrNull { it.isSent }?.accountNumber
val current = selectedFrom?.takeIf { s -> fromAccounts.any { it.accountNumber == s.accountNumber } }
selectedFrom = current ?: fromAccounts.firstOrNull { it.accountNumber == lastUsed } ?: fromAccounts.first()
selectFrom(selectedFrom!!)
}
private fun onTransferDone() {
childFragmentManager.findFragmentByTag(TAG_HIDDEN_TRANSFER)?.let {
childFragmentManager.beginTransaction().remove(it).commitAllowingStateLoss()
}
binding.etAmount.text = null
binding.etNote.text = null
// The receipt shows the new bubble right away; history catches up a little later and
// replaces it with the bank's own entry.
sync()
viewLifecycleOwner.lifecycleScope.launch {
delay(HISTORY_CATCH_UP_MS)
sync()
}
// Only offer the receipt this payment made; QR payments don't save one.
val receipt = ReceiptStore.loadAll(requireContext()).firstOrNull()?.takeIf { it.savedAt >= payStartedAt }
val merchant = thread?.isMerchant == true
Snackbar.make(binding.root, if (merchant) R.string.chat_payment_sent else R.string.chat_transfer_sent, Snackbar.LENGTH_LONG)
.setAnchorView(if (binding.composer.visibility == View.VISIBLE) binding.composer else binding.btnPayAgain)
.apply { if (receipt != null) setAction(R.string.chat_view_receipt) { openReceipt(receipt.savedAt.toString()) } }
.show()
}
private fun openReceipt(receiptKey: String) {
val entry = ReceiptStore.loadAll(requireContext()).firstOrNull { it.savedAt.toString() == receiptKey }
if (entry == null) {
Snackbar.make(binding.root, R.string.chat_receipt_missing, Snackbar.LENGTH_SHORT).show()
return
}
(requireActivity() as HomeActivity).showWithBackStack(TransferReceiptFragment.newInstance(entry.data, null))
}
private fun sync() {
val ctx = requireContext().applicationContext
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) }
if (_binding != null) loadThread()
}
}
private fun selectFrom(account: BankAccount) {
val b = _binding ?: return
selectedFrom = account
bindFromChip()
b.tilAmount.prefixText = account.currencyName
updateCurrencyWarning()
}
/** The chip shows the account (or, in a business chat, the card) paid from, with its balance. */
private fun bindFromChip() {
val b = _binding ?: return
val account = (if (thread?.isMerchant == true) selectedCard else selectedFrom) ?: return
val balance = AccountListParser.from(account)?.balance.orEmpty()
val hide = viewModel.hideAmounts.value ?: false
val money = when {
balance.isBlank() -> account.currencyName
hide -> AccountHistoryAdapter.maskAmount(balance)
else -> balance
}
b.btnFrom.text = getString(R.string.chat_from_chip, account.accountBriefName, account.accountNumber, money)
b.btnFrom.contentDescription = getString(R.string.chat_from_account) + ": " + b.btnFrom.text
// A card shows its network logo up front, in full colour; an account keeps the dropdown arrow.
val logo = if (thread?.isMerchant == true) sh.sar.basedbank.util.bmlapi.BmlCardParser.cardNetworkIcon(account) else null
val density = resources.displayMetrics.density
if (logo != null) {
b.btnFrom.setIconResource(logo)
b.btnFrom.iconTint = null
b.btnFrom.iconGravity = com.google.android.material.button.MaterialButton.ICON_GRAVITY_START
b.btnFrom.iconSize = (28 * density).toInt()
} else {
b.btnFrom.setIconResource(R.drawable.ic_arrow_right)
b.btnFrom.iconTint = android.content.res.ColorStateList.valueOf(
MaterialColors.getColor(b.btnFrom, com.google.android.material.R.attr.colorOnSecondaryContainer))
b.btnFrom.iconGravity = com.google.android.material.button.MaterialButton.ICON_GRAVITY_END
b.btnFrom.iconSize = (16 * density).toInt()
}
}
private fun showCardPicker() {
if (cardAccounts.isEmpty()) return
val ctx = requireContext()
val dialog = BottomSheetDialog(ctx)
val list = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val pad = (8 * resources.displayMetrics.density).toInt()
setPadding(0, pad, 0, pad * 3)
}
list.addView(sheetTitle(getString(R.string.chat_pick_card)))
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
for (card in cardAccounts) {
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
bindAccountRow(row, card)
sh.sar.basedbank.util.bmlapi.BmlCardParser.cardNetworkIcon(card)?.let {
row.ivDropdownCardLogo.setImageResource(it)
row.ivDropdownCardLogo.visibility = View.VISIBLE
}
if (card.accountNumber == selectedCard?.accountNumber) row.root.setBackgroundColor(selectedBg)
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
row.root.setOnClickListener {
selectedCard = card
bindFromChip()
dialog.dismiss()
}
list.addView(row.root)
}
dialog.setContentView(list)
dialog.show()
}
/**
* MVR can't be sent into a USD account: say so and disable Send. USD into an MVR account is
* converted by BML at its rate: allowed, with a warning.
*/
private fun updateCurrencyWarning() {
val b = _binding ?: return
val from = selectedFrom?.currencyName.orEmpty()
val to = selectedTo?.currency.orEmpty()
val blocked = isBlockedPair(from, to)
val converts = from.equals("USD", ignoreCase = true) && to.equals("MVR", ignoreCase = true)
b.tvCurrencyWarning.text = when {
blocked -> getString(R.string.chat_currency_blocked)
converts -> getString(R.string.chat_currency_warning)
else -> null
}
b.tvCurrencyWarning.visibility = if (blocked || converts) View.VISIBLE else View.GONE
b.btnSend.isEnabled = !blocked
}
/** MVR → USD account: BML doesn't allow it. */
private fun isBlockedPair(fromCurrency: String, toCurrency: String) =
fromCurrency.equals("MVR", ignoreCase = true) && toCurrency.equals("USD", ignoreCase = true)
private fun hasUsdSource() = fromAccounts.any { it.currencyName.equals("USD", ignoreCase = true) }
/** Looks up a saved static QR for this business: with one, the button pays it directly. */
private fun bindMerchantQr(t: ChatThread) {
val ctx = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
val qr = withContext(Dispatchers.IO) { ChatStore.merchantQr(ctx, t.peerName) }
val b = _binding ?: return@launch
merchantQr = qr
b.btnPayAgain.text = if (qr != null) getString(R.string.chat_pay_business, t.peerName)
else getString(R.string.chat_pay_again)
// A saved QR is paid from a card picked here: show just the card chip above the button.
b.composer.visibility = if (qr != null && cardAccounts.isNotEmpty()) View.VISIBLE else View.GONE
b.amountRow.visibility = View.GONE
b.tvCurrencyWarning.visibility = View.GONE
if (qr != null && selectedCard == null) {
val lastCard = t.messages.lastOrNull { m -> cardAccounts.any { it.accountNumber == m.accountNumber } }?.accountNumber
val defaultCard = CredentialStore(requireContext()).getDefaultCardAccountNumber()
selectedCard = cardAccounts.firstOrNull { it.accountNumber == lastCard }
?: cardAccounts.firstOrNull { it.accountNumber == defaultCard }
?: cardAccounts.firstOrNull()
}
bindFromChip()
}
}
/** Saved static QR: pay it in the sheet over the chat. Otherwise: open the scanner. */
private fun payBusiness() {
val qr = merchantQr
if (qr == null) {
(requireActivity() as HomeActivity).showWithBackStack(TransferFragment.newInstanceWithAutoScan())
return
}
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
val transfer = TransferFragment.newInstanceFromBmlQr(qr, selectedCard?.accountNumber, returnOnSuccess = true)
payStartedAt = System.currentTimeMillis()
hideKeyboard()
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
}
/** This month with this person (sent / received / net) or at this business (spent / payments). */
private fun bindSummary(t: ChatThread) {
val b = _binding ?: return
val monthStart = java.util.Calendar.getInstance().apply {
set(java.util.Calendar.DAY_OF_MONTH, 1)
set(java.util.Calendar.HOUR_OF_DAY, 0); set(java.util.Calendar.MINUTE, 0)
set(java.util.Calendar.SECOND, 0); set(java.util.Calendar.MILLISECOND, 0)
}.timeInMillis
val thisMonth = t.messages.filter { it.timeMillis >= monthStart }
if (thisMonth.isEmpty()) { b.summaryCard.visibility = View.GONE; return }
// One currency per card: the selected account's, else the one used most this month.
val currency = selectedTo?.currency?.takeIf { c -> thisMonth.any { it.currency == c } }
?: thisMonth.groupingBy { it.currency }.eachCount().maxByOrNull { it.value }!!.key
val ms = thisMonth.filter { it.currency == currency }
val hide = viewModel.hideAmounts.value ?: false
fun money(v: Double) = if (hide) "$currency ••••" else "$currency ${"%,.2f".format(v)}"
val month = java.text.SimpleDateFormat("MMMM", java.util.Locale.getDefault()).format(java.util.Date())
b.summaryCard.visibility = View.VISIBLE
val sent = ms.filter { it.isSent }.sumOf { -it.amount }
if (t.isMerchant) {
b.tvSumLabel1.text = getString(R.string.chat_sum_spent, month)
b.tvSumValue1.text = money(sent)
b.sumColumn2.visibility = View.GONE
b.tvSumLabel3.text = getString(R.string.chat_sum_payments)
b.tvSumValue3.text = ms.size.toString()
return
}
val received = ms.filter { !it.isSent }.sumOf { it.amount }
val night = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_YES
b.sumColumn2.visibility = View.VISIBLE
b.tvSumLabel1.text = getString(R.string.chat_sum_sent, month)
b.tvSumValue1.text = money(sent)
b.tvSumValue1.setTextColor(MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorError))
b.tvSumLabel2.text = getString(R.string.chat_sum_received)
b.tvSumValue2.text = money(received)
b.tvSumValue2.setTextColor(android.graphics.Color.parseColor(if (night) "#81C784" else "#2E7D32"))
b.tvSumLabel3.text = getString(R.string.chat_sum_net)
val net = received - sent
b.tvSumValue3.text = if (hide) "••••" else (if (net < 0) "−" else "+") + "%,.2f".format(kotlin.math.abs(net))
}
/** Long-press on a bubble: send the same again, open its receipt, or copy its reference. */
private fun showMessageMenu(m: sh.sar.basedbank.util.ChatMessage, anchor: View) {
val t = thread ?: return
val menu = android.widget.PopupMenu(requireContext(), anchor, android.view.Gravity.END)
val amount = "%.2f".format(kotlin.math.abs(m.amount))
val canSendAgain = m.isSent && !t.isMerchant && selectedTo != null
if (canSendAgain) menu.menu.add(0, 1, 0, getString(R.string.chat_send_again, "${m.currency} $amount"))
if (m.receiptKey.isNotBlank()) menu.menu.add(0, 2, 1, R.string.chat_view_receipt_full)
if (m.reference.isNotBlank()) menu.menu.add(0, 3, 2, R.string.chat_copy_reference)
if (menu.menu.size() == 0) return
menu.setOnMenuItemClickListener { item ->
when (item.itemId) {
1 -> {
// Same account it went to, when that account is still one of theirs.
t.accounts.firstOrNull { it.account == m.peerAccount }?.let { selectTo(it, matchFromCurrency = true) }
binding.etAmount.setText(amount)
binding.etNote.setText(m.note)
send()
}
2 -> openReceipt(m.receiptKey)
3 -> {
val clipboard = requireContext().getSystemService(android.content.ClipboardManager::class.java)
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("reference", m.reference))
Snackbar.make(binding.root, R.string.chat_reference_copied, Snackbar.LENGTH_SHORT).show()
}
}
true
}
menu.show()
}
/** Fills an account row: name, full account number, balance (masked when amounts are hidden). */
private fun bindAccountRow(row: ItemAccountDropdownBinding, account: BankAccount) {
val display = AccountListParser.from(account)
row.tvDropdownAccountName.text = account.accountBriefName
row.tvDropdownAccountNumber.text = account.accountNumber
val balance = display?.balance.orEmpty()
val hide = viewModel.hideAmounts.value ?: false
row.tvDropdownBalance.text = if (hide && balance.isNotBlank()) AccountHistoryAdapter.maskAmount(balance) else balance
val type = display?.typeLabel.orEmpty()
row.tvDropdownAccountType.text = type
row.tvDropdownAccountType.visibility = if (type.isBlank()) View.GONE else View.VISIBLE
row.ivDropdownCardLogo.visibility = View.GONE
}
private fun showAccountPicker() {
if (fromAccounts.isEmpty()) return
val ctx = requireContext()
val dialog = BottomSheetDialog(ctx)
val list = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val pad = (8 * resources.displayMetrics.density).toInt()
setPadding(0, pad, 0, pad * 3)
}
list.addView(sheetTitle(getString(R.string.chat_pick_account)))
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
for (account in fromAccounts) {
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
bindAccountRow(row, account)
// MVR can't go into a USD account: show those accounts, but not selectable.
val blocked = isBlockedPair(account.currencyName, selectedTo?.currency.orEmpty())
if (blocked) {
row.tvDropdownAccountType.text = getString(R.string.chat_cant_send_mvr_to_usd)
row.tvDropdownAccountType.visibility = View.VISIBLE
row.root.alpha = 0.4f
}
if (account.accountNumber == selectedFrom?.accountNumber) row.root.setBackgroundColor(selectedBg)
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
if (!blocked) row.root.setOnClickListener {
selectFrom(account)
dialog.dismiss()
}
list.addView(row.root)
}
dialog.setContentView(list)
dialog.show()
}
private fun send() {
val t = thread ?: return
val from = selectedFrom ?: return
val to = selectedTo ?: return
if (isBlockedPair(from.currencyName, to.currency)) return
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull()
if (amount == null || amount <= 0.0) {
binding.tilAmount.error = getString(R.string.chat_amount_invalid)
return
}
binding.tilAmount.error = null
val transfer = TransferFragment.newInstanceFromQr(
accountNumber = to.account,
displayName = to.contact?.benefNickName?.takeIf { it.isNotBlank() } ?: t.peerName,
amount = "%.2f".format(amount),
remarks = binding.etNote.text?.toString()?.trim()?.takeIf { it.isNotBlank() },
fromAccountNumber = from.accountNumber,
returnOnSuccess = true,
autoConfirm = true
)
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
payStartedAt = System.currentTimeMillis()
hideKeyboard()
if (isBusinessProfile(from)) {
// Business profiles confirm with an OTP typed into the Transfer page: show it in a sheet.
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
} else {
// Personal profiles confirm automatically: run the Transfer page hidden, so only its
// confirm dialog appears over the chat. A new send replaces any earlier one.
childFragmentManager.beginTransaction()
.replace(R.id.hiddenTransferHost, transfer, TAG_HIDDEN_TRANSFER)
.commit()
}
}
/** Same rule as the Transfer page: the account's BML login profile is a business one. */
private fun isBusinessProfile(account: BankAccount): Boolean {
val profiles = app.bmlProfilesMap[account.loginTag.removePrefix("bml_")] ?: return false
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
}
/** Closes the composer's keyboard so the transfer sheet doesn't open pushed up by it. */
private fun hideKeyboard() {
val b = _binding ?: return
val focused = b.root.findFocus() ?: return
requireContext().getSystemService(android.view.inputmethod.InputMethodManager::class.java)
?.hideSoftInputFromWindow(focused.windowToken, 0)
focused.clearFocus()
}
/** Shows the photo, contact name and real name in the toolbar; tapping it opens the contact. */
private fun updateToolbar() {
val t = thread ?: return
val bar = (activity as? AppCompatActivity)?.supportActionBar ?: return
val h = header ?: ViewChatHeaderBinding.inflate(layoutInflater).also { h ->
header = h
h.root.setOnClickListener { openContact() }
}
h.tvHeaderName.text = t.peerName
// Real name (when titled with a nickname), plus the account and currency when there's only one.
val subtitle = if (t.isMerchant) getString(R.string.chat_merchant_subtitle) else listOfNotNull(
t.realName.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) },
selectedTo?.takeIf { t.accounts.size == 1 }?.account,
selectedTo?.takeIf { t.accounts.size == 1 }?.currency?.takeIf { it.isNotBlank() }
).joinToString(" · ")
h.tvHeaderAccount.text = subtitle
h.tvHeaderAccount.visibility = if (subtitle.isBlank()) View.GONE else View.VISIBLE
val sizePx = (40 * resources.displayMetrics.density).toInt()
if (t.isMerchant) {
h.ivHeaderAvatar.shapeAppearanceModel = com.google.android.material.shape.ShapeAppearanceModel.builder()
.setAllCornerSizes(10 * resources.displayMetrics.density).build()
h.ivHeaderAvatar.setImageBitmap(ChatsAdapter.shopBitmap(h.root, ChatsAdapter.merchantColor(t.peerKey), sizePx))
} else {
val photo = t.contact?.customerImgHash?.let { ContactImageCache.load(requireContext(), it) }
h.ivHeaderAvatar.setImageBitmap(photo ?: contactInitialsBitmap(t.peerName, ChatsAdapter.avatarColor(t.peerKey), sizePx))
}
requireActivity().title = t.peerName
bar.setDisplayShowTitleEnabled(false)
bar.setDisplayShowCustomEnabled(true)
bar.customView = h.root
}
private fun openContact() {
val t = thread ?: return
if (t.isMerchant) return
val display = (selectedTo?.contact ?: t.contact)?.let { ContactListParser.from(it) }
if (display != null) {
if (childFragmentManager.findFragmentByTag("contact_details") != null) return
ContactDetailsSheetFragment.newInstance(display).show(childFragmentManager, "contact_details")
} else {
// Not a saved contact yet: offer to save them, prefilled with what we know.
if (childFragmentManager.findFragmentByTag("add_contact") != null) return
AddContactSheetFragment.newInstance(
bmlProfileId = selectedFrom?.profileId?.takeIf { it.isNotBlank() },
accountNumber = selectedTo?.account,
recipientName = t.realName.ifBlank { t.peerName },
currency = selectedTo?.currency?.takeIf { it.isNotBlank() } ?: selectedFrom?.currencyName
).show(childFragmentManager, "add_contact")
}
}
override fun openTransfer(contact: ContactDisplay) {
val fragment = TransferFragment.newInstance(
accountNumber = contact.accountNumber,
displayName = contact.name,
subtitle = contact.transferSubtitle,
colorHex = contact.bankColor,
imageHash = contact.imageHash
)
(requireActivity() as HomeActivity).showWithBackStack(fragment)
}
/** Deleting lives on the Contacts page, which also keeps its caches in sync. */
override fun confirmDelete(contact: ContactDisplay) {
(requireActivity() as HomeActivity).showWithBackStack(ContactsFragment())
}
override fun onResume() {
super.onResume()
updateToolbar()
}
override fun onPause() {
super.onPause()
(activity as? AppCompatActivity)?.supportActionBar?.apply {
setDisplayShowCustomEnabled(false)
setDisplayShowTitleEnabled(true)
customView = null
}
}
override fun onDestroyView() {
super.onDestroyView()
header = null
_binding = null
}
companion object {
private const val ARG_PEER_KEY = "peer_key"
private const val HISTORY_CATCH_UP_MS = 10_000L
private const val TAG_HIDDEN_TRANSFER = "transfer_hidden"
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
private val CARD_TYPES = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT")
fun newInstance(peerKey: String) = ChatFragment().apply {
arguments = Bundle().apply { putString(ARG_PEER_KEY, peerKey) }
}
}
}
@@ -0,0 +1,205 @@
package sh.sar.basedbank.ui.home
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.format.DateUtils
import android.text.style.StyleSpan
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.shape.ShapeAppearanceModel
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ItemChatBubbleBinding
import sh.sar.basedbank.databinding.ItemChatDateBinding
import sh.sar.basedbank.util.ChatMessage
/**
* One chat's transfers or payments as compact bubbles — sent / paid on the right, received on the
* left — with ✓ (seen in a BML alert) or ✓✓ (booked in history). Day separators carry the day's
* totals.
*/
class ChatMessagesAdapter(
private val onReceiptClick: (ChatMessage) -> Unit,
private val onLongPress: (ChatMessage, View) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
/** Rows of the list: a day separator or a bubble. */
sealed class Item {
data class DateChip(val day: String, val messages: List<ChatMessage>) : Item()
data class Bubble(val message: ChatMessage) : Item()
}
private val items = mutableListOf<Item>()
private var hideAmounts = false
private var todayLabel = ""
private var yesterdayLabel = ""
/** When the person has several accounts, sent bubbles say which one the money went to. */
var showDestination = false
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyDataSetChanged()
}
/** [messages] must be oldest first. */
fun setMessages(messages: List<ChatMessage>, todayLabel: String, yesterdayLabel: String) {
this.todayLabel = todayLabel
this.yesterdayLabel = yesterdayLabel
items.clear()
messages.groupBy { it.date.take(10) }.forEach { (day, dayMessages) ->
items.add(Item.DateChip(day, dayMessages))
dayMessages.forEach { items.add(Item.Bubble(it)) }
}
notifyDataSetChanged()
}
override fun getItemCount() = items.size
override fun getItemViewType(position: Int) = when (items[position]) {
is Item.DateChip -> TYPE_DATE
is Item.Bubble -> TYPE_BUBBLE
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return if (viewType == TYPE_DATE) DateVH(ItemChatDateBinding.inflate(inflater, parent, false))
else BubbleVH(ItemChatBubbleBinding.inflate(inflater, parent, false))
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = items[position]) {
is Item.DateChip -> (holder as DateVH).bind(item)
is Item.Bubble -> (holder as BubbleVH).bind(item.message)
}
}
private fun formatAmount(m: ChatMessage) =
if (hideAmounts) "${m.currency} ••••••" else "${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}"
private fun isNight(view: View) =
(view.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
private fun ticksFor(m: ChatMessage) = if (m.isBooked) "✓✓" else "✓"
private fun tickColor(view: View, m: ChatMessage, fallback: Int) =
if (m.isBooked) Color.parseColor(if (isNight(view)) "#90CAF9" else "#1565C0") else fallback
inner class DateVH(private val b: ItemChatDateBinding) : RecyclerView.ViewHolder(b.root) {
fun bind(item: Item.DateChip) {
val first = item.messages.first()
val label = when {
DateUtils.isToday(first.timeMillis) -> todayLabel
DateUtils.isToday(first.timeMillis + DateUtils.DAY_IN_MILLIS) -> yesterdayLabel
else -> AccountHistoryAdapter.formatDateHeader(first.date)
}
val text = SpannableStringBuilder(label)
text.setSpan(StyleSpan(Typeface.BOLD), 0, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
if (!hideAmounts) {
// Day totals per currency: ↑ sent, ↓ received.
val parts = item.messages.groupBy { it.currency }.map { (ccy, ms) ->
val sent = ms.filter { it.isSent }.sumOf { -it.amount }
val received = ms.filter { !it.isSent }.sumOf { it.amount }
listOfNotNull(
"↑ %.2f".format(sent).takeIf { sent > 0 },
"↓ %.2f".format(received).takeIf { received > 0 }
).joinToString(" · ").let { if (item.messages.map { m -> m.currency }.toSet().size > 1) "$ccy $it" else it }
}.filter { it.isNotBlank() }
if (parts.isNotEmpty()) text.append(" ").append(parts.joinToString(" "))
}
b.tvDate.text = text
}
}
inner class BubbleVH(private val b: ItemChatBubbleBinding) : RecyclerView.ViewHolder(b.root) {
fun bind(m: ChatMessage) {
val ctx = b.root.context
val sent = m.isSent
val night = isNight(b.root)
val density = ctx.resources.displayMetrics.density
b.bubbleRow.gravity = if (sent) Gravity.END else Gravity.START
(b.cardBubble.layoutParams as LinearLayout.LayoutParams).gravity = if (sent) Gravity.END else Gravity.START
// Rounded, with a small "tail" corner on the sender's side at the bottom.
val rtl = b.root.layoutDirection == View.LAYOUT_DIRECTION_RTL
val big = 18 * density
val tail = 4 * density
val tailOnRight = sent != rtl
b.cardBubble.shapeAppearanceModel = ShapeAppearanceModel.builder()
.setTopLeftCornerSize(big).setTopRightCornerSize(big)
.setBottomLeftCornerSize(if (tailOnRight) big else tail)
.setBottomRightCornerSize(if (tailOnRight) tail else big)
.build()
// Sent: tinted. Received: neutral with an outline, so the two read apart on any theme.
val bg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorErrorContainer)
else Color.parseColor(if (night) "#2A2C2E" else "#FFFFFF")
val fg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnErrorContainer)
else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurface)
val green = Color.parseColor(if (night) "#81C784" else "#2E7D32")
b.cardBubble.setCardBackgroundColor(bg)
b.cardBubble.strokeWidth = if (sent) 0 else density.toInt()
b.cardBubble.strokeColor = MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOutlineVariant)
b.tvAmount.text = if (sent) formatAmount(m) else "+ ${formatAmount(m)}"
b.tvAmount.setTextColor(if (sent) fg else green)
b.tvNote.text = m.note
b.tvNote.setTextColor(fg)
b.tvNote.visibility = if (m.note.isBlank()) View.GONE else View.VISIBLE
b.btnReceipt.visibility = if (m.receiptKey.isNotBlank()) View.VISIBLE else View.GONE
b.btnReceipt.setColorFilter(fg)
b.btnReceipt.setOnClickListener { onReceiptClick(m) }
b.tvAccount.text = when {
!sent -> ctx.getString(R.string.chat_into_short, m.accountDisplayName)
showDestination && m.peerAccount.isNotBlank() ->
ctx.getString(R.string.chat_account_to, m.accountDisplayName, m.peerAccount)
else -> m.accountDisplayName
}
b.tvAccount.setTextColor(fg)
b.tvTime.text = AccountHistoryAdapter.formatTime(m.date)
b.tvTime.setTextColor(fg)
b.tvTicks.text = ticksFor(m)
b.tvTicks.setTextColor(tickColor(b.root, m, ColorUtils.setAlphaComponent(fg, 0xB3)))
b.tvTicks.contentDescription = ctx.getString(if (m.isBooked) R.string.chat_tick_booked else R.string.chat_tick_seen)
b.cardBubble.setOnClickListener { showDetail(b.root, m) }
b.cardBubble.setOnLongClickListener { onLongPress(m, b.cardBubble); true }
}
}
private fun showDetail(anchor: View, m: ChatMessage) {
val ctx = anchor.context
val details = buildString {
val sign = if (m.isSent) "-" else "+"
append("Amount\n$sign ${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}\n\n")
append("Date\n${AccountHistoryAdapter.formatFullDate(m.date)}\n\n")
if (m.note.isNotBlank()) append("Remarks\n${m.note}\n\n")
if (m.reference.isNotBlank()) append("Reference\n${m.reference}\n\n")
append("Account\n${m.accountDisplayName}\n\n")
append("Status\n${ctx.getString(if (m.isBooked) R.string.chat_tick_booked else R.string.chat_tick_seen)}")
}
MaterialAlertDialogBuilder(ctx)
.setTitle(m.peerName)
.setMessage(details)
.setPositiveButton("OK", null)
.show()
}
companion object {
private const val TYPE_DATE = 0
private const val TYPE_BUBBLE = 1
}
}
@@ -0,0 +1,172 @@
package sh.sar.basedbank.ui.home
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import com.google.android.material.shape.ShapeAppearanceModel
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ItemChatBinding
import sh.sar.basedbank.util.ChatThread
import sh.sar.basedbank.util.ContactImageCache
/**
* Telegram-style chat list: one row per person or business, latest transfer as the preview.
* Pinned chats come first; long-press selects a chat (for pinning).
*/
class ChatsAdapter(
private val onChatClick: (ChatThread) -> Unit,
private val onChatLongPress: (ChatThread) -> Unit
) : RecyclerView.Adapter<ChatsAdapter.ViewHolder>() {
private var allThreads: List<ChatThread> = emptyList()
private var displayed: List<ChatThread> = emptyList()
private var pinned: List<String> = emptyList()
private var searchQuery = ""
private var hideAmounts = false
/** Chat key shown as selected (long-pressed), or null. */
var selectedKey: String? = null
set(value) { field = value; notifyDataSetChanged() }
fun updateThreads(threads: List<ChatThread>, pinnedKeys: List<String>) {
allThreads = threads
pinned = pinnedKeys
applyFilter()
}
fun setSearch(query: String) {
searchQuery = query
applyFilter()
}
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyDataSetChanged()
}
fun isPinned(key: String) = key in pinned
val isEmpty get() = displayed.isEmpty()
private fun applyFilter() {
val matching = if (searchQuery.isBlank()) allThreads else allThreads.filter {
it.peerName.contains(searchQuery, ignoreCase = true) ||
it.realName.contains(searchQuery, ignoreCase = true) ||
it.peerAccount.contains(searchQuery) ||
it.messages.any { m -> m.note.contains(searchQuery, ignoreCase = true) }
}
// Pinned first, most recently pinned on top; the rest stay newest first.
val (pinnedThreads, others) = matching.partition { it.peerKey in pinned }
displayed = pinnedThreads.sortedBy { pinned.indexOf(it.peerKey) } + others
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val holder = ViewHolder(ItemChatBinding.inflate(LayoutInflater.from(parent.context), parent, false))
holder.binding.root.setOnClickListener {
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onChatClick(displayed[pos])
}
holder.binding.root.setOnLongClickListener {
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onChatLongPress(displayed[pos])
pos != RecyclerView.NO_POSITION
}
return holder
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(displayed[position])
override fun getItemCount() = displayed.size
inner class ViewHolder(val binding: ItemChatBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(thread: ChatThread) {
val ctx = binding.root.context
val night = (ctx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
val last = thread.last
binding.tvName.text = thread.peerName
val amount = if (hideAmounts) "${last.currency} ••••••"
else "${last.currency} ${"%.2f".format(kotlin.math.abs(last.amount))}"
val ticks = if (last.isBooked) " ✓✓" else " ✓"
binding.tvPreview.text = when {
last.merchant -> ctx.getString(R.string.chat_preview_paid, amount) + ticks
last.isSent -> {
val note = last.note.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty()
ctx.getString(R.string.chat_preview_sent, amount) + note + ticks
}
else -> ctx.getString(R.string.chat_preview_received, amount)
}
binding.tvPreview.setTextColor(
if (!last.isSent) Color.parseColor(if (night) "#81C784" else "#2E7D32")
else MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorOnSurfaceVariant)
)
binding.tvTime.text = if (DateUtils.isToday(last.timeMillis)) AccountHistoryAdapter.formatTime(last.date)
else AccountHistoryAdapter.formatDateOnly(last.date)
val sizePx = (52 * ctx.resources.displayMetrics.density).toInt()
if (thread.isMerchant) {
// Businesses get a rounded-square shop icon; people keep round initials or a photo.
binding.ivAvatar.shapeAppearanceModel = ShapeAppearanceModel.builder()
.setAllCornerSizes(14 * ctx.resources.displayMetrics.density).build()
binding.ivAvatar.setImageBitmap(shopBitmap(binding.root, merchantColor(thread.peerKey), sizePx))
} else {
binding.ivAvatar.shapeAppearanceModel = ShapeAppearanceModel.builder().setAllCornerSizes(sizePx / 2f).build()
val photo = thread.contact?.customerImgHash?.let { ContactImageCache.load(ctx, it) }
binding.ivAvatar.setImageBitmap(photo ?: contactInitialsBitmap(thread.peerName, avatarColor(thread.peerKey), sizePx))
}
val isPinned = thread.peerKey in pinned
binding.ivPin.visibility = if (isPinned) View.VISIBLE else View.GONE
// Selected (long-pressed): tinted; pinned: a soft surface; otherwise the normal ripple.
when {
thread.peerKey == selectedKey -> binding.root.setBackgroundColor(
MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorErrorContainer))
isPinned -> binding.root.setBackgroundColor(
MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorSurfaceContainerHigh))
else -> binding.root.setBackgroundResource(selectableBackground(binding.root))
}
}
}
private fun selectableBackground(view: View): Int {
val out = android.util.TypedValue()
view.context.theme.resolveAttribute(android.R.attr.selectableItemBackground, out, true)
return out.resourceId
}
companion object {
private val AVATAR_COLORS = listOf("#E8B04B", "#5C9CE6", "#E57373", "#66BB6A", "#9575CD", "#4DB6AC", "#F06292", "#FF8A65")
private val MERCHANT_COLORS = listOf("#6D4C41", "#546E7A", "#5D4037", "#455A64", "#8D6E63", "#37474F")
/** Stable per-person avatar colour, so a chat keeps its colour between visits. */
fun avatarColor(peerKey: String) = AVATAR_COLORS[(peerKey.hashCode() and 0x7fffffff) % AVATAR_COLORS.size]
fun merchantColor(peerKey: String) = MERCHANT_COLORS[(peerKey.hashCode() and 0x7fffffff) % MERCHANT_COLORS.size]
/** Shop icon on a coloured square; the image view's shape rounds the corners. */
fun shopBitmap(view: View, colorHex: String, sizePx: Int): Bitmap {
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
canvas.drawRect(RectF(0f, 0f, sizePx.toFloat(), sizePx.toFloat()),
Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor(colorHex) })
val icon = ContextCompat.getDrawable(view.context, R.drawable.ic_store)?.mutate() ?: return bm
val inset = sizePx / 4
icon.setBounds(inset, inset, sizePx - inset, sizePx - inset)
icon.draw(canvas)
return bm
}
}
}
@@ -0,0 +1,261 @@
package sh.sar.basedbank.ui.home
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentChatsBinding
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.ChatStore
import sh.sar.basedbank.util.ChatThread
import java.text.DateFormat
import java.util.Date
/**
* Transfers grouped by person or business, shown as a chat list. Contacts is reachable from the
* toolbar; long-press a chat to pin it to the top.
*/
class ChatsFragment : Fragment() {
private var _binding: FragmentChatsBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private val app get() = requireActivity().application as BasedBankApp
private lateinit var adapter: ChatsAdapter
private val scanLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
onQrScanned(raw)
}
private var actionMode: ActionMode? = null
private var syncedThisView = false
private var syncedWithContacts = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentChatsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = ChatsAdapter(
onChatClick = { thread ->
if (actionMode != null) actionMode?.finish()
else (requireActivity() as HomeActivity).showWithBackStack(ChatFragment.newInstance(thread.peerKey))
},
onChatLongPress = { thread -> startSelection(thread) }
)
binding.rvChats.layoutManager = LinearLayoutManager(requireContext())
binding.rvChats.adapter = adapter
binding.etSearch.addTextChangedListener { text ->
adapter.setSearch(text?.toString() ?: "")
updateEmptyView()
}
binding.swipeRefresh.setOnRefreshListener { sync() }
// Scan to Pay: opens the QR scanner; after paying it comes back here and refreshes.
binding.fabScanPay.setOnClickListener {
scanLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
sync()
// BML's alert often lands a few seconds after the payment: check again shortly.
viewLifecycleOwner.lifecycleScope.launch {
delay(RECHECK_AFTER_PAYMENT_MS)
if (_binding != null) sync()
}
}
// Keep the button above the system navigation bar when there's no bottom bar.
val fabMargin = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.fabScanPay) { v, insets ->
val bottomNav = NavCustomization.getNavMode(
requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE)
) == NavCustomization.NAV_MODE_BOTTOM
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
(v.layoutParams as android.widget.FrameLayout.LayoutParams).bottomMargin =
fabMargin + if (bottomNav) 0 else navBar
v.requestLayout()
insets
}
requireActivity().addMenuProvider(object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menu.add(Menu.NONE, R.id.action_open_contacts, 0, R.string.nav_contacts)
.setIcon(R.drawable.ic_contacts)
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
if (menuItem.itemId != R.id.action_open_contacts) return false
(requireActivity() as HomeActivity).showWithBackStack(ContactsFragment())
return true
}
}, viewLifecycleOwner, Lifecycle.State.RESUMED)
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
// Contacts give chats a saved name and an account number to send to.
(activity as? HomeActivity)?.loadAllContacts()
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
loadThreads()
// Contacts usually load after the first sync; sync again so their real names get looked up.
if (syncedThisView && !syncedWithContacts && contacts.isNotEmpty()) sync()
}
// Accounts arrive after login; sync once they are available.
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
if (!syncedThisView && accounts.any { it.bank == "BML" }) sync()
}
loadThreads()
}
private fun sync() {
syncedThisView = true
binding.swipeRefresh.isRefreshing = true
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
if (contacts.isNotEmpty()) syncedWithContacts = true
val ctx = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) }
_binding?.swipeRefresh?.isRefreshing = false
loadThreads()
}
}
private fun loadThreads() {
val ctx = requireContext().applicationContext
val contacts = viewModel.contacts.value ?: emptyList()
viewLifecycleOwner.lifecycleScope.launch {
val (threads, pins) = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) to ChatStore.pins(ctx) }
if (_binding == null) return@launch
adapter.updateThreads(threads, pins)
updateEmptyView()
}
}
/**
* A scanned QR: BML Scan to Pay (static or card-machine) opens the payment, a PayMV QR opens a
* transfer. Either way, success comes back to this list, which then refreshes.
*/
private fun onQrScanned(raw: String) {
val activity = requireActivity() as HomeActivity
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (bmlTarget != null) {
activity.showWithBackStack(TransferFragment.newInstanceFromBmlQr(bmlTarget, null, returnOnSuccess = true))
return
}
val qr = PaymvQrParser.parse(raw)
val account = qr?.accountNumber
if (account == null) {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
return
}
activity.showWithBackStack(TransferFragment.newInstanceFromQr(
accountNumber = account,
displayName = qr.merchantName ?: account,
amount = qr.amount,
remarks = qr.purpose,
returnOnSuccess = true
))
}
/** Long-press: select the chat and offer Pin / Unpin in the top bar. */
private fun startSelection(thread: ChatThread) {
adapter.selectedKey = thread.peerKey
val pinned = adapter.isPinned(thread.peerKey)
val callback = object : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.title = getString(R.string.chat_selected_one)
menu.add(Menu.NONE, R.id.action_pin_chat, 0, if (pinned) R.string.chat_unpin else R.string.chat_pin)
.setIcon(R.drawable.ic_pin)
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_WITH_TEXT)
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu) = false
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
if (item.itemId != R.id.action_pin_chat) return false
setPinned(thread, !pinned, offerUndo = true)
mode.finish()
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
actionMode = null
adapter.selectedKey = null
}
}
actionMode?.finish()
actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(callback)
}
private fun setPinned(thread: ChatThread, pin: Boolean, offerUndo: Boolean) {
val ctx = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) { ChatStore.setPinned(ctx, thread.peerKey, pin) }
loadThreads()
val b = _binding ?: return@launch
if (!offerUndo) return@launch
Snackbar.make(b.root, getString(if (pin) R.string.chat_pinned_msg else R.string.chat_unpinned_msg, thread.peerName), Snackbar.LENGTH_LONG)
.setAction(R.string.chat_undo) { setPinned(thread, !pin, offerUndo = false) }
.show()
}
}
private fun updateEmptyView() {
val b = _binding ?: return
b.emptyView.visibility = if (adapter.isEmpty) View.VISIBLE else View.GONE
if (!adapter.isEmpty) return
val since = ChatStore.sinceMillis(requireContext())
b.emptyView.text = when {
b.etSearch.text?.isNotBlank() == true -> getString(R.string.chats_no_results)
since > 0L -> getString(R.string.chats_empty_since,
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(since)))
else -> getString(R.string.chats_empty)
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_chats)
}
override fun onDestroyView() {
actionMode?.finish()
super.onDestroyView()
_binding = null
}
companion object {
private const val RECHECK_AFTER_PAYMENT_MS = 10_000L
}
}
@@ -24,14 +24,14 @@ import sh.sar.basedbank.util.ContactListParser
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.TransferNetwork import sh.sar.basedbank.util.TransferNetwork
/** Contact details drawer shown when a row in [ContactsFragment] is tapped. */ /** Contact details drawer shown when a contact is tapped in [ContactsFragment] or a chat header. */
class ContactDetailsSheetFragment : BottomSheetDialogFragment() { class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
private var _binding: SheetContactDetailsBinding? = null private var _binding: SheetContactDetailsBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private val contactsFragment get() = parentFragment as? ContactsFragment private val contactsFragment get() = parentFragment as? ContactSheetHost
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = SheetContactDetailsBinding.inflate(inflater, container, false) _binding = SheetContactDetailsBinding.inflate(inflater, container, false)
@@ -0,0 +1,9 @@
package sh.sar.basedbank.ui.home
import sh.sar.basedbank.util.ContactDisplay
/** A screen that can show [ContactDetailsSheetFragment] and handle its Transfer/Delete buttons. */
interface ContactSheetHost {
fun openTransfer(contact: ContactDisplay)
fun confirmDelete(contact: ContactDisplay)
}
@@ -40,7 +40,7 @@ import sh.sar.basedbank.util.ContactManager
import sh.sar.basedbank.util.ContactsCache import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.TransferNetwork import sh.sar.basedbank.util.TransferNetwork
class ContactsFragment : Fragment() { class ContactsFragment : Fragment(), ContactSheetHost {
private var _binding: FragmentContactsBinding? = null private var _binding: FragmentContactsBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
@@ -182,7 +182,7 @@ class ContactsFragment : Fragment() {
ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details") ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details")
} }
internal fun openTransfer(contact: ContactDisplay) { override fun openTransfer(contact: ContactDisplay) {
val fragment = TransferFragment.newInstance( val fragment = TransferFragment.newInstance(
accountNumber = contact.accountNumber, accountNumber = contact.accountNumber,
displayName = contact.name, displayName = contact.name,
@@ -193,7 +193,7 @@ class ContactsFragment : Fragment() {
(requireActivity() as HomeActivity).showWithBackStack(fragment) (requireActivity() as HomeActivity).showWithBackStack(fragment)
} }
internal fun confirmDelete(contact: ContactDisplay) { override fun confirmDelete(contact: ContactDisplay) {
val ctx = requireContext() val ctx = requireContext()
val errorColor = MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorError) 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 icon = ContextCompat.getDrawable(ctx, R.drawable.ic_delete)?.mutate()?.apply { setTint(errorColor) }
@@ -176,6 +176,7 @@ class HomeActivity : AppCompatActivity() {
val frag = when (item.itemId) { val frag = when (item.itemId) {
R.id.nav_dashboard -> DashboardFragment() R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment() R.id.nav_accounts -> AccountsFragment()
R.id.nav_chats -> ChatsFragment()
R.id.nav_contacts -> ContactsFragment() R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it } R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
R.id.nav_pay_mv_qr -> PayMvQrFragment() R.id.nav_pay_mv_qr -> PayMvQrFragment()
@@ -473,6 +474,7 @@ fun applyNavLabelVisibility() {
val dest = fragment ?: when (itemId) { val dest = fragment ?: when (itemId) {
R.id.nav_dashboard -> DashboardFragment() R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment() R.id.nav_accounts -> AccountsFragment()
R.id.nav_chats -> ChatsFragment()
R.id.nav_contacts -> ContactsFragment() R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it } R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
R.id.nav_pay_mv_qr -> PayMvQrFragment() R.id.nav_pay_mv_qr -> PayMvQrFragment()
@@ -528,10 +530,6 @@ fun applyNavLabelVisibility() {
private fun routeSharedQrText(text: String) { private fun routeSharedQrText(text: String) {
val store = CredentialStore(this) val store = CredentialStore(this)
sh.sar.basedbank.api.bml.BmlMerchantTxnClient.parseTransactionId(text)?.let {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlTxn(it))
return
}
val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text) val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
if (bmlTarget != null) { if (bmlTarget != null) {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber())) navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
@@ -32,6 +32,7 @@ object NavCustomization {
/** All items that can occupy either a bottom nav slot or the "More" screen. */ /** All items that can occupy either a bottom nav slot or the "More" screen. */
val ALL_SWAPPABLE = listOf( val ALL_SWAPPABLE = listOf(
NavItemDef(R.id.nav_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts), NavItemDef(R.id.nav_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
NavItemDef(R.id.nav_chats, "nav_chats", R.drawable.ic_chat, R.string.nav_chats, R.string.nav_desc_chats),
NavItemDef(R.id.nav_contacts, "nav_contacts", R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts), NavItemDef(R.id.nav_contacts, "nav_contacts", R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
NavItemDef(R.id.nav_transfer, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer), NavItemDef(R.id.nav_transfer, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
NavItemDef(R.id.nav_pay_mv_qr, "nav_pay_mv_qr", R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr), NavItemDef(R.id.nav_pay_mv_qr, "nav_pay_mv_qr", R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
@@ -51,14 +52,14 @@ object NavCustomization {
fun getSlots(prefs: SharedPreferences): List<Int> = listOf( fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts), keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts),
keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_contacts), keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_chats),
keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer), keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer),
) )
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) { fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
prefs.edit() prefs.edit()
.putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts") .putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts")
.putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_contacts") .putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_chats")
.putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer") .putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer")
.apply() .apply()
} }
@@ -79,7 +80,7 @@ object NavCustomization {
fun getCircularSlots(prefs: SharedPreferences): List<Int> = listOf( fun getCircularSlots(prefs: SharedPreferences): List<Int> = listOf(
keyToId(prefs.getString("circular_slot_1_key", null), R.id.nav_transfer), keyToId(prefs.getString("circular_slot_1_key", null), R.id.nav_transfer),
keyToId(prefs.getString("circular_slot_2_key", null), R.id.nav_pay_with_card), keyToId(prefs.getString("circular_slot_2_key", null), R.id.nav_pay_with_card),
keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_contacts), keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_chats),
keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts), keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts),
) )
@@ -87,7 +88,7 @@ object NavCustomization {
prefs.edit() prefs.edit()
.putString("circular_slot_1_key", idToKey(slots[0]) ?: "nav_transfer") .putString("circular_slot_1_key", idToKey(slots[0]) ?: "nav_transfer")
.putString("circular_slot_2_key", idToKey(slots[1]) ?: "nav_pay_with_card") .putString("circular_slot_2_key", idToKey(slots[1]) ?: "nav_pay_with_card")
.putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_contacts") .putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_chats")
.putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts") .putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts")
.apply() .apply()
} }
@@ -1,25 +1,14 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.app.Activity
import android.content.ClipData import android.content.ClipData
import android.content.ClipDescription
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.PersistableBundle
import android.text.Editable
import android.text.TextWatcher
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 androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -30,22 +19,14 @@ 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 com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
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.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
import sh.sar.basedbank.databinding.DialogOtpExportSeedBinding
import sh.sar.basedbank.databinding.DialogOtpUpdateSeedBinding
import sh.sar.basedbank.databinding.FragmentOtpBinding import sh.sar.basedbank.databinding.FragmentOtpBinding
import sh.sar.basedbank.databinding.ItemOtpCardBinding import sh.sar.basedbank.databinding.ItemOtpCardBinding
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.OtpauthParser
import sh.sar.basedbank.util.Totp import sh.sar.basedbank.util.Totp
class OtpFragment : Fragment() { class OtpFragment : Fragment() {
@@ -53,35 +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( private data class OtpEntry(val bank: String, val name: String?, val seed: String)
val bank: String, val loginId: String, val account: String, val name: String?, val seed: String
)
private val entries = mutableListOf<OtpEntry>()
private var adapter: OtpAdapter? = null
/** Seed field of the open "Update seed" dialog, filled by the QR scanner result. */
private var scanTarget: android.widget.EditText? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val target = scanTarget ?: return@registerForActivityResult
val found = OtpauthParser.parse(raw)
when {
found.isEmpty() -> Toast.makeText(requireContext(), "No OTP data found in QR", Toast.LENGTH_SHORT).show()
found.size == 1 -> target.setText(found[0].secret)
else -> {
val labels = found.map { e ->
if (e.issuer.isNotBlank()) "${e.issuer} (${e.name})" else e.name.ifBlank { e.secret.take(8) + "…" }
}.toTypedArray()
MaterialAlertDialogBuilder(requireContext())
.setTitle("Choose account")
.setItems(labels) { _, i -> target.setText(found[i].secret) }
.show()
}
}
}
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>() {
@@ -103,8 +56,6 @@ class OtpFragment : Fragment() {
) )
update(b, entry.seed) update(b, entry.seed)
b.root.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") } b.root.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
// Long-press opens the seed menu (export / update)
b.root.setOnLongClickListener { showSeedMenu(it, holder.bindingAdapterPosition); true }
b.btnCopyOtp.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") } b.btnCopyNextOtp.setOnClickListener { copyCode(it.context, b.tvNextOtpCode.text, "Next OTP copied") }
} }
@@ -140,170 +91,6 @@ class OtpFragment : Fragment() {
} }
} }
private fun showSeedMenu(anchor: View, position: Int) {
if (position == RecyclerView.NO_POSITION) return
val popup = PopupMenu(anchor.context, anchor)
popup.menu.add(0, 1, 0, "Export seed")
popup.menu.add(0, 2, 1, "Update seed")
popup.setOnMenuItemClickListener { item ->
val entry = entries.getOrNull(position) ?: return@setOnMenuItemClickListener false
when (item.itemId) {
1 -> showExportDialog(entry)
2 -> showUpdateDialog(position)
}
true
}
popup.show()
}
private fun entryTitle(entry: OtpEntry) = "${entry.bank} · ${entry.name ?: entry.account}"
// ── Export ───────────────────────────────────────────────────────────────
private fun showExportDialog(entry: OtpEntry) {
val ctx = requireContext()
val d = DialogOtpExportSeedBinding.inflate(layoutInflater)
val uri = OtpauthParser.buildUri(entry.bank, entry.seed)
d.tvSeed.text = entry.seed.chunked(4).joinToString(" ")
renderQr(uri, (220 * resources.displayMetrics.density).toInt())?.let { d.ivSeedQr.setImageBitmap(it) }
d.btnCopySeed.setOnClickListener { copySensitive(ctx, entry.seed, "Seed copied") }
MaterialAlertDialogBuilder(ctx)
.setTitle(entryTitle(entry))
.setView(d.root)
.setPositiveButton("Done", null)
.show()
}
private fun renderQr(content: String, size: Int): Bitmap? = try {
val hints = mapOf(
EncodeHintType.MARGIN to 0,
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
)
val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
val pixels = IntArray(size * size) { i -> if (matrix[i % size, i / size]) Color.BLACK else Color.WHITE }
Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
} catch (_: Exception) { null }
/** Copy a secret, flagged sensitive so Android 13+ hides it in the clipboard preview. */
private fun copySensitive(context: Context, text: String, message: String) {
val clip = ClipData.newPlainText("OTP seed", text)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
clip.description.extras = PersistableBundle().apply {
putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
}
}
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
// ── Update ───────────────────────────────────────────────────────────────
private fun showUpdateDialog(position: Int) {
val entry = entries.getOrNull(position) ?: return
val ctx = requireContext()
val d = DialogOtpUpdateSeedBinding.inflate(layoutInflater)
d.tvSeedWarning.text = "Saving will replace the current seed for this login, and the old one " +
"can't be recovered. If you might still need it, export it first."
var newSeed: String? = null
// Same behaviour as the sign-in screen's preview card
val preview = d.cardOtp
preview.root.setOnClickListener { copyCode(it.context, preview.tvOtpCode.text, "OTP copied") }
fun refreshPreview() {
val seed = newSeed
try {
if (seed == null) throw IllegalArgumentException()
preview.tvOtpCode.text = Totp.generate(seed)
preview.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
preview.otpTimer.max = 30
preview.otpTimer.progress = 30 - (System.currentTimeMillis() / 1000L % 30).toInt()
preview.root.visibility = View.VISIBLE
} catch (_: Exception) {
preview.root.visibility = View.INVISIBLE
}
}
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle("Update seed · ${entry.bank}")
.setView(d.root)
.setPositiveButton("Replace", null)
.setNegativeButton("Cancel", null)
.create()
fun validate() {
val raw = d.etNewSeed.text?.toString().orEmpty()
newSeed = OtpauthParser.resolveSecret(raw)
d.tilNewSeed.error = when {
raw.isBlank() -> null
newSeed == null -> "Not a valid TOTP seed"
newSeed == entry.seed -> "This is already the current seed"
else -> null
}
if (newSeed == entry.seed) newSeed = null
dialog.getButton(DialogInterface.BUTTON_POSITIVE)?.isEnabled = newSeed != null
refreshPreview()
}
d.etNewSeed.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) = validate()
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
})
d.btnScanNewSeed.setOnClickListener {
scanTarget = d.etNewSeed
qrLauncher.launch(Intent(ctx, QrScannerActivity::class.java))
}
val ticker = viewLifecycleOwner.lifecycleScope.launch {
while (isActive) { refreshPreview(); delay(1_000) }
}
dialog.setOnDismissListener {
ticker.cancel()
if (scanTarget === d.etNewSeed) scanTarget = null
}
dialog.setOnShowListener {
val replace = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
replace.isEnabled = false
replace.setOnClickListener {
val seed = newSeed ?: return@setOnClickListener
confirmReplace(entry) {
saveSeed(position, seed)
dialog.dismiss()
}
}
}
dialog.show()
}
private fun confirmReplace(entry: OtpEntry, onConfirm: () -> Unit) {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Delete old seed?")
.setMessage("The current seed for ${entryTitle(entry)} will be replaced and can't be " +
"recovered afterwards.")
.setPositiveButton("Replace") { _, _ -> onConfirm() }
.setNegativeButton("Cancel", null)
.show()
}
private fun saveSeed(position: Int, seed: String) {
val entry = entries.getOrNull(position) ?: return
val store = CredentialStore(requireContext())
when (entry.bank) {
"MIB" -> {
store.updateMibOtpSeed(entry.loginId, seed)
// The live flow keeps the seed in memory for silent re-login
(requireActivity().application as BasedBankApp).mibFlowFor(entry.loginId).updateOtpSeed(seed)
}
"BML" -> store.updateBmlOtpSeed(entry.loginId, seed)
}
entries[position] = entry.copy(seed = seed)
adapter?.notifyItemChanged(position)
Toast.makeText(requireContext(), "Seed updated", Toast.LENGTH_SHORT).show()
}
private fun copyCode(context: Context, text: CharSequence, message: String) { private fun copyCode(context: Context, text: CharSequence, message: String) {
val code = text.toString().replace(" ", "") val code = text.toString().replace(" ", "")
if (code.isEmpty() || code.contains('-')) return if (code.isEmpty() || code.contains('-')) return
@@ -328,19 +115,16 @@ class OtpFragment : Fragment() {
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)
tagged.add(CredentialStore.loginKey("mib", loginId) to tagged.add(CredentialStore.loginKey("mib", loginId) to OtpEntry("MIB", name, creds.otpSeed))
OtpEntry("MIB", loginId, creds.username, 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
tagged.add(CredentialStore.loginKey("bml", loginId) to tagged.add(CredentialStore.loginKey("bml", loginId) to OtpEntry("BML", name?.takeIf { it.isNotBlank() }, creds.otpSeed))
OtpEntry("BML", loginId, creds.username, name?.takeIf { it.isNotBlank() }, creds.otpSeed))
} }
entries.clear() val entries = tagged.sortedBy { rank(it.first) }.map { it.second }.toMutableList()
entries.addAll(tagged.sortedBy { rank(it.first) }.map { it.second })
val adapter = OtpAdapter(entries).also { this.adapter = it } 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 binding.emptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
@@ -363,7 +147,8 @@ class OtpFragment : Fragment() {
mobile = profile.mobile, mobile = profile.mobile,
enrolled = profile.enrolled enrolled = profile.enrolled
)) ))
val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId } val seed = store.loadMibCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed }
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true } if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
} }
} }
@@ -383,7 +168,8 @@ class OtpFragment : Fragment() {
idCard = info.idCard, idCard = info.idCard,
birthdate = info.birthdate birthdate = info.birthdate
)) ))
val idx = entries.indexOfFirst { it.bank == "BML" && it.loginId == loginId } val seed = store.loadBmlCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed }
if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true } if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true }
} }
} }
@@ -406,8 +192,6 @@ class OtpFragment : Fragment() {
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
adapter = null
scanTarget = null
_binding = null _binding = null
} }
} }
@@ -7,15 +7,12 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.text.TextPaint
import android.text.TextUtils
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.* import android.widget.*
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
@@ -68,18 +65,7 @@ class PayMvQrFragment : Fragment() {
private data class QrTarget(val accountNumber: String, val name: String, val bank: String) private data class QrTarget(val accountNumber: String, val name: String, val bank: String)
private fun currentTarget(): QrTarget? = contactTarget private fun currentTarget(): QrTarget? = contactTarget
?: selectedAccount?.let { QrTarget(it.accountNumber, qrHolderName(it), it.bank) } ?: selectedAccount?.let { QrTarget(it.accountNumber, it.accountBriefName, it.bank) }
/** Name printed on the card and put in the payload (tag 59). */
private fun qrHolderName(account: BankAccount): String = when {
// Fahipay's brief name is the generic "Fahipay Wallet"; the holder's name is on the profile
account.bank == "FAHIPAY" -> account.profileName.takeIf { it.isNotBlank() && it != "Fahipay" }
?: CredentialStore(requireContext())
.loadFahipayUserProfile(sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag))
?.fullName?.takeIf { it.isNotBlank() }
?: account.accountBriefName
else -> account.accountBriefName
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
contactTarget = arguments?.let { args -> contactTarget = arguments?.let { args ->
@@ -160,13 +146,11 @@ class PayMvQrFragment : Fragment() {
"FAHIPAY" -> "FAHIMVMV" "FAHIPAY" -> "FAHIMVMV"
else -> "MADVMVMV" else -> "MADVMVMV"
} }
val amountRaw = binding.etAmount.text?.toString()?.trim()?.replace(",", "") val amountFormatted = binding.etAmount.text?.toString()?.trim()
val amountFormatted = amountRaw ?.replace(",", "")
?.toDoubleOrNull() ?.toDoubleOrNull()
?.takeIf { it > 0 } ?.takeIf { it > 0 }
?.let { "%.2f".format(it) } ?.let { "%.2f".format(it) }
// BML shows the amount on the card as typed (no forced decimals)
val amountDisplay = amountRaw?.takeIf { amountFormatted != null }
val ctx = requireContext() val ctx = requireContext()
val account = selectedAccount val account = selectedAccount
@@ -181,28 +165,17 @@ class PayMvQrFragment : Fragment() {
when { when {
m.startsWith("+") -> m m.startsWith("+") -> m
m.length == 7 -> "+960$m" m.length == 7 -> "+960$m"
m.length == 10 && m.startsWith("960") -> "+$m" // Fahipay stores 960XXXXXXX
else -> m else -> m
} }
} }
} else null } else null
val purpose = binding.etReference.text?.toString()?.trim() val purpose = binding.etReference.text?.toString()?.trim()
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
// The reference (62/05) is also printed vertically beside the QR, as each bank does
val reference = when (target.bank) {
// BML: base-32 account number followed by the amount as typed
"BML" -> ((target.accountNumber.toBigIntegerOrNull()?.toString(32)?.uppercase() ?: "") +
(amountDisplay ?: "")).take(25).ifEmpty { generateReference(9) }
"FAHIPAY" -> "P" + generateReference(9) // Fahipay's own references are P + 9 chars
else -> generateReference(9)
}
val bmp = withContext(Dispatchers.Default) { val bmp = withContext(Dispatchers.Default) {
val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose, reference, target.bank) val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose)
if (target.bank == "FAHIPAY") renderFahipayQrCard(ctx, target, payload, reference) renderQrCard(ctx, target, payload, amountFormatted)
else renderQrCard(ctx, target, payload, reference)
} }
if (_binding == null) return if (_binding == null) return
generatedBitmap = bmp generatedBitmap = bmp
@@ -221,19 +194,14 @@ class PayMvQrFragment : Fragment() {
acquirer: String, acquirer: String,
amountStr: String?, amountStr: String?,
mobile: String?, mobile: String?,
purpose: String?, purpose: String
ref: String,
bank: String
): String { ): String {
fun tlv(tag: String, value: String): String { fun tlv(tag: String, value: String): String {
val len = value.length val len = value.length
return tag + (if (len < 10) "0$len" else "$len") + value return tag + (if (len < 10) "0$len" else "$len") + value
} }
val format = tlv("00", "01") val format = tlv("00", "01")
// Fahipay's own QRs are dynamic (12) when they carry an amount and mask the amount val poi = tlv("01", "11")
// as "***" when they don't; its scanner may reject QRs that differ
val fahipay = bank == "FAHIPAY"
val poi = tlv("01", if (fahipay && !amountStr.isNullOrBlank()) "12" else "11")
val sub00 = tlv("00", "mv.favara.mpqr") val sub00 = tlv("00", "mv.favara.mpqr")
val sub01 = tlv("01", acquirer) val sub01 = tlv("01", acquirer)
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
@@ -243,28 +211,21 @@ class PayMvQrFragment : Fragment() {
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10) val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
val mcc = tlv("52", "0000") val mcc = tlv("52", "0000")
val currency = tlv("53", "462") val currency = tlv("53", "462")
val amountTLV = when { val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
!amountStr.isNullOrBlank() -> tlv("54", amountStr)
fahipay -> tlv("54", "***")
else -> ""
}
val country = tlv("58", "MV") val country = tlv("58", "MV")
val name = tlv("59", accountName.uppercase().take(25)) val name = tlv("59", accountName.take(25))
// Fahipay's QRs always carry a city ("LD" + 4 digits) and default the purpose to PAYMENT val ref = generateReference()
val city = if (fahipay) tlv("60", "LD" + (0..9999).random().toString().padStart(4, '0')) else "" val addlData = tlv("62", tlv("05", ref) + tlv("08", purpose))
val purposeText = purpose?.takeIf { it.isNotBlank() } ?: if (fahipay) "PAYMENT" else null
val purposeTLV = if (purposeText != null) tlv("08", purposeText) else ""
val addlData = tlv("62", tlv("05", ref) + purposeTLV)
val timestamp = java.time.LocalDateTime.now() val timestamp = java.time.LocalDateTime.now()
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000")) .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp)) val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp))
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + city + addlData + tag80 + "6304" val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + addlData + tag80 + "6304"
return prefix + crc16(prefix) return prefix + crc16(prefix)
} }
private fun generateReference(length: Int): String { private fun generateReference(): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return (1..length).map { chars.random() }.joinToString("") return (1..9).map { chars.random() }.joinToString("")
} }
private fun crc16(data: String): String { private fun crc16(data: String): String {
@@ -281,252 +242,88 @@ class PayMvQrFragment : Fragment() {
// ── QR card rendering ──────────────────────────────────────────────────── // ── QR card rendering ────────────────────────────────────────────────────
/**
* Replicates the BML app's ReceiveCard (React Native, v2.1.47) 1:1. All measurements are in
* dp, as in BML's StyleSheet, laid out for BML's reference screen width and drawn at
* [PX_PER_DP] pixels per dp.
*/
private fun renderQrCard( private fun renderQrCard(
ctx: Context, ctx: Context,
target: QrTarget, target: QrTarget,
qrPayload: String, qrPayload: String,
qrId: String amountStr: String?
): Bitmap { ): Bitmap {
val sw = SCREEN_WIDTH_DP val W = 900
fun px(dp: Float) = dp * PX_PER_DP val H = 1080
val mmaBlue = Color.parseColor("#0E5CA4") val outerCorner = 48f
val boxBlue = Color.parseColor("#2272B7")
val footerBlue = Color.parseColor("#1A5799")
val boxL = 24f; val boxT = 110f; val boxR = 876f; val boxB = 962f
val cardW = sw - 48f // screen's horizontal margins, spacing[5] each side val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888)
val qrSize = sw * 0.5f
val railW = sw / 1.85f
val namePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = px(14f)
typeface = Typeface.DEFAULT
textAlign = Paint.Align.CENTER
}
val footerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = px(sw * 0.046f)
typeface = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD
letterSpacing = 1.2f / (sw * 0.046f) // RN letterSpacing is in dp, Paint's is in em
textAlign = Paint.Align.CENTER
}
// Android RN Text lines include font padding: line height = bottom - top
fun lineHeight(p: Paint) = p.fontMetrics.let { it.bottom - it.top } / PX_PER_DP
// --- Vertical layout (dp, card-local) ---
val topCardTop = 2f
val brandTop = topCardTop + 32f // brandRow marginTop spacing[6]
val logoBoxW = sw * 0.38f // bml-logo-paymv box: 0.38·sw wide
val logoBoxH = logoBoxW * 0.1116751269035533f
val payMvBoxW = sw * 0.2f // paymv-logo box: 0.2·sw wide
val payMvBoxH = payMvBoxW * 0.17333333333333334f
val brandH = maxOf(logoBoxH, payMvBoxH)
val nameText = target.name.uppercase()
val hasName = nameText.isNotBlank()
val qrCardTop = brandTop + brandH + if (hasName) 24f else 32f
val nameTop = qrCardTop + 8f + 12f // qrCard paddingTop spacing[2], name marginTop spacing[3]
val nameH = if (hasName) lineHeight(namePaint) else 0f
val qrTop = if (hasName) nameTop + nameH + 16f else qrCardTop + 37f
val qrCardBottom = qrTop + qrSize + 37f // paddingBottom spacing[6] + 5
val topCardBottom = qrCardBottom + 24f + 8f // qrCard marginBottom, topCard paddingBottom
val footerLineH = lineHeight(footerPaint)
val cardH = topCardBottom + 12f + footerLineH + 12f + 2f
val bm = Bitmap.createBitmap(px(cardW).toInt(), px(cardH).toInt(), Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm) val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG) val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// captureWrapper: mmaBlue, radius 20 — shows as a 2dp border around the white top card // Clip to outer rounded card shape
val outerPath = Path().apply { val outerPath = Path()
addRoundRect(RectF(0f, 0f, px(cardW), px(cardH)), px(20f), px(20f), Path.Direction.CW) outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW)
}
canvas.clipPath(outerPath) canvas.clipPath(outerPath)
canvas.drawColor(mmaBlue) canvas.drawColor(Color.WHITE)
// topCard: white, 2dp inset, top corners 18 // --- Bank logo top-left ---
paint.color = Color.WHITE val logoRes = when (target.bank) {
val r = px(18f) "BML" -> R.drawable.bml_logo_vector
canvas.drawPath(Path().apply { "MIB" -> R.drawable.mib_faisanet_logo
addRoundRect( else -> R.drawable.fahipay_logo_long
RectF(px(2f), px(topCardTop), px(cardW - 2f), px(topCardBottom)), }
floatArrayOf(r, r, r, r, 0f, 0f, 0f, 0f), Path.Direction.CW
)
}, paint)
// --- brandRow: "BANK OF MALDIVES" wordmark left, "PayMV QR" right, 40dp side margins ---
val rowL = 2f + 40f
val rowR = cardW - 2f - 40f
val rowCenterY = brandTop + brandH / 2
val logoRes = if (target.bank == "BML") R.drawable.bml_logo_paymv else R.drawable.mib_faisanet_logo
AppCompatResources.getDrawable(ctx, logoRes)?.let { d -> AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
val nW = d.intrinsicWidth.coerceAtLeast(1) val nW = d.intrinsicWidth.coerceAtLeast(1)
val nH = d.intrinsicHeight.coerceAtLeast(1) val nH = d.intrinsicHeight.coerceAtLeast(1)
// resizeMode "contain" inside the logo box, left-aligned in the row val maxW = 180f; val maxH = 76f
val scale = minOf(px(logoBoxW) / nW, px(logoBoxH) / nH) val scale = minOf(maxW / nW, maxH / nH)
val lW = (nW * scale).toInt() val lW = (nW * scale).toInt()
val lH = (nH * scale).toInt() val lH = (nH * scale).toInt()
val lLeft = (px(rowL) + (px(logoBoxW) - lW) / 2f).toInt() val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10)
val lTop = (px(rowCenterY) - lH / 2f).toInt() d.setBounds(24, lTop, 24 + lW, lTop + lH)
d.setBounds(lLeft, lTop, lLeft + lW, lTop + lH)
d.draw(canvas) d.draw(canvas)
} }
// BML draws the paymv-logo image (fills its box exactly), nudged down 1dp
paint.color = mmaBlue // --- "PayMV QR" top-right ---
paint.typeface = footerPaint.typeface paint.color = Color.parseColor("#1A1A2E")
paint.textSize = 36f
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textAlign = Paint.Align.RIGHT paint.textAlign = Paint.Align.RIGHT
paint.textSize = px(payMvBoxH) canvas.drawText("PayMV QR", W - 28f, 66f, paint)
paint.textSize *= px(payMvBoxW) / paint.measureText("PayMV QR")
val payMvBounds = Rect().also { paint.getTextBounds("PayMV QR", 0, 8, it) }
canvas.drawText(
"PayMV QR", px(rowR),
px(rowCenterY + 1f) - payMvBounds.exactCenterY(), paint
)
// --- qrCard: mmaBlue, radius 16, 40dp side margins --- // --- Blue rounded box ---
val qrCardL = 2f + 40f paint.color = boxBlue
val qrCardR = cardW - 2f - 40f paint.textAlign = Paint.Align.LEFT
paint.color = mmaBlue canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint)
canvas.drawRoundRect(RectF(px(qrCardL), px(qrCardTop), px(qrCardR), px(qrCardBottom)), px(16f), px(16f), paint)
if (hasName) { // Account name (white, bold, uppercase, auto-scaled to fit)
val maxNameW = px(qrCardR - qrCardL)
if (namePaint.measureText(nameText) > maxNameW) {
namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
}
canvas.drawText(nameText, px(cardW / 2), px(nameTop) - namePaint.fontMetrics.top, namePaint)
}
// QR — white modules on mmaBlue, ECL M, no quiet zone (react-native-qrcode-svg defaults)
val qrPx = px(qrSize).toInt()
val qrLeft = px(cardW / 2) - qrPx / 2f
try {
val hints = mapOf(
EncodeHintType.MARGIN to 0,
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
)
val matrix = QRCodeWriter().encode(qrPayload, BarcodeFormat.QR_CODE, qrPx, qrPx, hints)
val pixels = IntArray(qrPx * qrPx)
for (y in 0 until qrPx) {
for (x in 0 until qrPx) {
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else mmaBlue
}
}
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
canvas.drawBitmap(qrBm, qrLeft, px(qrTop), null)
qrBm.recycle()
} catch (_: Exception) { /* skip if encoding fails */ }
// qrIdRail: the reference, rotated -90°, beside the QR's right edge
if (qrId.isNotEmpty()) {
val idPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
alpha = (255 * 0.8f).toInt()
textSize = px(10f)
typeface = Typeface.DEFAULT
textAlign = Paint.Align.CENTER
}
val qrRight = cardW / 2 + qrSize / 2
val cx = px(qrRight + railW / 1.37f - railW / 2)
val cy = px(qrTop + (qrSize + railW / 1.5f) / 2)
val idText = TextUtils.ellipsize(qrId, TextPaint(idPaint), px(railW), TextUtils.TruncateAt.END).toString()
canvas.save()
canvas.rotate(-90f, cx, cy)
val fm = idPaint.fontMetrics
canvas.drawText(idText, cx, cy - (fm.bottom + fm.top) / 2, idPaint)
canvas.restore()
}
// --- footer: SofiaPro-Bold, letterSpacing 1.2 ---
canvas.drawText(
"MALDIVES NATIONAL QR", px(cardW / 2),
px(topCardBottom + 12f) - footerPaint.fontMetrics.top, footerPaint
)
return bm
}
/**
* Replicates the card Fahipay's server renders for PayMV QR (api/app/qr/), measured
* in pixels on its 1240×1322 image. Fonts follow the BML card (Sofia Pro Bold, Roboto),
* except the vertical reference, which is Montserrat as on Fahipay's.
*/
private fun renderFahipayQrCard(
ctx: Context,
target: QrTarget,
qrPayload: String,
reference: String
): Bitmap {
val w = 1240f
val h = 1322f
val blue = Color.parseColor("#005DA3")
val sofiaBold = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD
val montserrat = ResourcesCompat.getFont(ctx, R.font.montserrat_regular) ?: Typeface.DEFAULT
val bm = Bitmap.createBitmap(w.toInt(), h.toInt(), Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Blue card with a 6px border around the white area; footer is the blue below it
canvas.clipPath(Path().apply {
addRoundRect(RectF(0f, 0f, w, h), 50f, 50f, Path.Direction.CW)
})
canvas.drawColor(blue)
paint.color = Color.WHITE paint.color = Color.WHITE
canvas.drawPath(Path().apply { paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
addRoundRect( paint.textAlign = Paint.Align.CENTER
RectF(6f, 6f, w - 6f, 1174f),
floatArrayOf(44f, 44f, 44f, 44f, 0f, 0f, 0f, 0f), Path.Direction.CW
)
}, paint)
// Square app icon, then the "FahiPay" wordmark, in one row
AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo)?.let { d ->
d.setBounds(94, 94, 163, 163)
d.draw(canvas)
}
AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo_long)?.let { d ->
val lH = 48
val lW = (lH * d.intrinsicWidth.toFloat() / d.intrinsicHeight.coerceAtLeast(1)).toInt()
d.setBounds(178, 104, 178 + lW, 104 + lH)
d.draw(canvas)
}
// Sized so capitals match the reference's cap heights; positions below are cap tops
fun textPaint(tf: Typeface, capH: Float, spacingEm: Float, align: Paint.Align) =
Paint(Paint.ANTI_ALIAS_FLAG).apply {
typeface = tf
letterSpacing = spacingEm
textAlign = align
textSize = 100f
val h = Rect().also { getTextBounds("H", 0, 1, it) }.height().coerceAtLeast(1)
textSize = 100f * capH / h
}
fun Paint.capHeight() = Rect().also { getTextBounds("H", 0, 1, it) }.height()
// "PayMV QR" top-right
val payMvPaint = textPaint(sofiaBold, 30f, 0f, Paint.Align.RIGHT).apply { color = blue }
canvas.drawText("PayMV QR", 1147f, 101f + 30f, payMvPaint)
// Blue QR panel
paint.color = blue
canvas.drawRoundRect(RectF(166f, 218f, 1074f, 1126f), 55f, 55f, paint)
// Account name
val nameText = target.name.uppercase() val nameText = target.name.uppercase()
if (nameText.isNotBlank()) { paint.textSize = 36f
val namePaint = textPaint(Typeface.DEFAULT, 30f, 0f, Paint.Align.CENTER).apply { color = Color.WHITE } val maxNameW = boxR - boxL - 48f
val maxNameW = 1074f - 166f - 80f if (paint.measureText(nameText) > maxNameW) {
if (namePaint.measureText(nameText) > maxNameW) { paint.textSize = 36f * maxNameW / paint.measureText(nameText)
namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
} }
canvas.drawText(nameText, 619f, 314f + namePaint.capHeight(), namePaint) val nameBaseline = boxT + 68f
canvas.drawText(nameText, W / 2f, nameBaseline, paint)
// Optional amount below name
val qrTopY: Float
if (!amountStr.isNullOrBlank()) {
paint.textSize = 28f
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
val amtBaseline = nameBaseline + 42f
canvas.drawText("MVR $amountStr", W / 2f, amtBaseline, paint)
qrTopY = amtBaseline + 20f
} else {
qrTopY = nameBaseline + 26f
} }
// QR — white modules on blue, no quiet zone // QR code — white modules on the same blue as the box background
val qrPx = 562 val availH = boxB - qrTopY - 24f
val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200)
val qrLeft = ((W - qrPx) / 2).toFloat()
try { try {
val hints = mapOf( val hints = mapOf(
EncodeHintType.MARGIN to 0, EncodeHintType.MARGIN to 0,
@@ -536,25 +333,23 @@ class PayMvQrFragment : Fragment() {
val pixels = IntArray(qrPx * qrPx) val pixels = IntArray(qrPx * qrPx)
for (y in 0 until qrPx) { for (y in 0 until qrPx) {
for (x in 0 until qrPx) { for (x in 0 until qrPx) {
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else blue pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else boxBlue
} }
} }
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888) val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
canvas.drawBitmap(qrBm, 338f, 417f, null) canvas.drawBitmap(qrBm, qrLeft, qrTopY, null)
qrBm.recycle() qrBm.recycle()
} catch (_: Exception) { /* skip if encoding fails */ } } catch (_: Exception) { /* skip if encoding fails */ }
// Reference, blue, reading bottom-to-top in the white margin right of the panel, // --- Dark blue footer ---
// starting level with y=1087 and with its baseline at x=1171 paint.color = footerBlue
val refPaint = textPaint(montserrat, 27f, 0.005f, Paint.Align.LEFT).apply { color = blue } paint.textAlign = Paint.Align.LEFT
canvas.save() canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint)
canvas.rotate(-90f, 1171f, 1087f) paint.color = Color.WHITE
canvas.drawText(reference, 1171f, 1087f, refPaint) paint.textSize = 32f
canvas.restore() paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textAlign = Paint.Align.CENTER
// Footer canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint)
val footerPaint = textPaint(sofiaBold, 55.5f, 1.2f / 25.76f, Paint.Align.CENTER).apply { color = Color.WHITE }
canvas.drawText("MALDIVES NATIONAL QR", w / 2, 1220f + 55.5f, footerPaint)
return bm return bm
} }
@@ -642,10 +437,6 @@ class PayMvQrFragment : Fragment() {
private const val ARG_ACCOUNT_NAME = "account_name" private const val ARG_ACCOUNT_NAME = "account_name"
private const val ARG_BANK = "bank" private const val ARG_BANK = "bank"
/** BML's layout reference: the screen width (dp) its ReceiveCard sizes were captured at. */
private const val SCREEN_WIDTH_DP = 560f
private const val PX_PER_DP = 2f
/** QR for a contact's account. [bank] is "BML" / "MIB" / "FAHIPAY", as on [BankAccount.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 { fun forContact(accountNumber: String, name: String, bank: String) = PayMvQrFragment().apply {
arguments = Bundle().apply { arguments = Bundle().apply {
@@ -167,12 +167,6 @@ class SettingsAppearanceFragment : Fragment() {
val isDark = prefs.getString("theme", "system") == "dark" val isDark = prefs.getString("theme", "system") == "dark"
updatePitchBlackState(isDark) updatePitchBlackState(isDark)
// Receipts
binding.switchFullscreenReceipt.isChecked = prefs.getBoolean("always_fullscreen_receipt", false)
binding.switchFullscreenReceipt.setOnCheckedChangeListener { _, checked ->
prefs.edit().putBoolean("always_fullscreen_receipt", checked).apply()
}
// Accent color // Accent color
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE) val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
binding.accentToggle.check(when (savedPreset) { binding.accentToggle.check(when (savedPreset) {
@@ -39,7 +39,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.bml.BmlMerchantTxnClient
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.models.BankContact
import sh.sar.basedbank.api.mib.MibIpsAccountInfo import sh.sar.basedbank.api.mib.MibIpsAccountInfo
@@ -81,8 +80,6 @@ class TransferFragment : Fragment() {
private var resolvedAccountNumber = "" private var resolvedAccountNumber = ""
private var resolvedRecipientName = "" private var resolvedRecipientName = ""
private var resolvedBankName = "" private var resolvedBankName = ""
/** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */
private var loadedToPhoto: Bitmap? = null
private var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown private var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
private var resolvedToOwnAccount: BankAccount? = null private var resolvedToOwnAccount: BankAccount? = null
@@ -118,7 +115,7 @@ class TransferFragment : Fragment() {
clearForm() clearForm()
val activity = requireActivity() as HomeActivity val activity = requireActivity() as HomeActivity
activity.triggerRefresh() activity.triggerRefresh()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar)) showReceipt(receipt, avatar)
} }
).also { bmlHandler = it } ).also { bmlHandler = it }
@@ -158,7 +155,7 @@ class TransferFragment : Fragment() {
clearForm() clearForm()
val activity = requireActivity() as HomeActivity val activity = requireActivity() as HomeActivity
activity.triggerRefresh() activity.triggerRefresh()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar)) showReceipt(receipt, avatar)
} }
).also { mfaisaHandler = it } ).also { mfaisaHandler = it }
@@ -178,17 +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 Merchant Services payment link — resolve it to the QR its page would show
BmlMerchantTxnClient.parseTransactionId(raw)?.let {
binding.etTo.setText(it)
lookupBmlMerchantTransaction(it)
return
}
// BML card/gateway/POS QR — hand off to dedicated payment screen // BML card/gateway/POS QR — hand off to dedicated payment screen
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw) val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (bmlTarget != null) { if (bmlTarget != null) {
openBmlQr(bmlTarget) val fromCard = selectedAccount?.takeIf {
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
}
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, fromCard?.accountNumber))
return return
} }
@@ -252,24 +245,24 @@ class TransferFragment : Fragment() {
private const val ARG_REMARKS_PREFILL = "remarks_prefill" private const val ARG_REMARKS_PREFILL = "remarks_prefill"
private const val ARG_BML_QR_URL = "bml_qr_url" private const val ARG_BML_QR_URL = "bml_qr_url"
private const val ARG_AUTO_SCAN = "auto_scan" private const val ARG_AUTO_SCAN = "auto_scan"
private const val ARG_BML_TXN_ID = "bml_txn_id" private const val ARG_RETURN_ON_SUCCESS = "return_on_success"
private const val ARG_AUTO_CONFIRM = "auto_confirm"
/** Fragment result sent instead of opening the receipt when [ARG_RETURN_ON_SUCCESS] is set. */
const val RESULT_TRANSFER_DONE = "transfer_done"
fun newInstanceWithAutoScan() = TransferFragment().apply { fun newInstanceWithAutoScan() = TransferFragment().apply {
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) } arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
} }
fun newInstanceFromBmlQr(qrUrl: String, fromAccountNumber: String? = null) = TransferFragment().apply { fun newInstanceFromBmlQr(qrUrl: String, fromAccountNumber: String? = null, returnOnSuccess: Boolean = false) = TransferFragment().apply {
arguments = Bundle().apply { arguments = Bundle().apply {
putString(ARG_BML_QR_URL, qrUrl) putString(ARG_BML_QR_URL, qrUrl)
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber) if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
if (returnOnSuccess) putBoolean(ARG_RETURN_ON_SUCCESS, true)
} }
} }
/** Opens on a BML Merchant Services transaction ID, which is resolved to its QR on load. */
fun newInstanceFromBmlTxn(transactionId: String) = TransferFragment().apply {
arguments = Bundle().apply { putString(ARG_BML_TXN_ID, transactionId) }
}
fun newInstanceFrom(account: BankAccount) = TransferFragment().apply { fun newInstanceFrom(account: BankAccount) = TransferFragment().apply {
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) } arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
} }
@@ -295,7 +288,9 @@ class TransferFragment : Fragment() {
displayName: String, displayName: String,
amount: String?, amount: String?,
remarks: String?, remarks: String?,
fromAccountNumber: String? = null fromAccountNumber: String? = null,
returnOnSuccess: Boolean = false,
autoConfirm: Boolean = false
) = TransferFragment().apply { ) = TransferFragment().apply {
arguments = Bundle().apply { arguments = Bundle().apply {
putString(ARG_ACCOUNT, accountNumber) putString(ARG_ACCOUNT, accountNumber)
@@ -305,10 +300,35 @@ class TransferFragment : Fragment() {
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber) if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount) if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks) if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
if (returnOnSuccess) putBoolean(ARG_RETURN_ON_SUCCESS, true)
if (autoConfirm) putBoolean(ARG_AUTO_CONFIRM, true)
} }
} }
} }
/**
* Opens the receipt, or — when opened with returnOnSuccess (e.g. from a chat) — goes back to
* the caller and tells it via [RESULT_TRANSFER_DONE] on the activity's fragment manager;
* the receipt is already in ReceiptStore.
*/
private fun showReceipt(receipt: TransferReceiptData, avatar: Bitmap?) {
if (!returnToCallerIfRequested()) {
(requireActivity() as HomeActivity).showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
}
}
/**
* When opened with returnOnSuccess, tells the caller the payment went through and goes back to
* it — closing the sheet this page is hosted in, if any. Returns false when not requested.
*/
internal fun returnToCallerIfRequested(): Boolean {
if (arguments?.getBoolean(ARG_RETURN_ON_SUCCESS) != true) return false
requireActivity().supportFragmentManager.setFragmentResult(RESULT_TRANSFER_DONE, Bundle())
val sheet = parentFragment as? androidx.fragment.app.DialogFragment
if (sheet != null) sheet.dismiss() else parentFragmentManager.popBackStack()
return true
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentTransferBinding.inflate(inflater, container, false) _binding = FragmentTransferBinding.inflate(inflater, container, false)
return binding.root return binding.root
@@ -391,12 +411,17 @@ class TransferFragment : Fragment() {
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) } arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) } arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
arguments?.getString(ARG_BML_QR_URL)?.let { bmlHandler().lookupQrMerchant(it) } // Opened fully filled in (e.g. from a chat): go straight to the confirm dialog, once. Posted
arguments?.getString(ARG_BML_TXN_ID)?.let { // so the source account, picked when the accounts arrive, is selected first.
// Shown in the To field so a failed lookup leaves the ID there to retry or correct. if (arguments?.getBoolean(ARG_AUTO_CONFIRM) == true) {
binding.etTo.setText(it) view.post {
lookupBmlMerchantTransaction(it) if (_binding == null || arguments?.getBoolean(ARG_AUTO_CONFIRM) != true) return@post
arguments?.remove(ARG_AUTO_CONFIRM)
if (selectedAccount != null && resolvedAccountNumber.isNotBlank()) initiateTransfer()
} }
}
arguments?.getString(ARG_BML_QR_URL)?.let { bmlHandler().lookupQrMerchant(it) }
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) { if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
launchQrScanner() launchQrScanner()
@@ -781,40 +806,7 @@ class TransferFragment : Fragment() {
setupContactDropdown() setupContactDropdown()
} }
/** Reopens the Transfer screen in BML merchant-QR mode, keeping a selected BML card as the source. */
private fun openBmlQr(bmlTarget: String) {
val fromCard = selectedAccount?.takeIf {
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
}
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, fromCard?.accountNumber))
}
/**
* A BML Merchant Services transaction ID (or its payment link) typed into the To field: fetch
* the QR the payment page would show and pay it like a scanned one.
*/
private fun lookupBmlMerchantTransaction(transactionId: String) {
startLookupLoading()
viewLifecycleOwner.lifecycleScope.launch {
val target = withContext(Dispatchers.IO) {
runCatching { BmlMerchantTxnClient().fetchQrPayload(transactionId) }
.getOrNull()?.let { PaymvQrParser.bmlQrPayTarget(it) }
}
if (_binding == null) return@launch
stopLookupLoading()
if (target == null) {
binding.tilTo.error = getString(R.string.transfer_bml_txn_lookup_failed)
return@launch
}
openBmlQr(target)
}
}
private fun searchTo() { private fun searchTo() {
BmlMerchantTxnClient.parseTransactionId(binding.etTo.text?.toString().orEmpty())?.let {
lookupBmlMerchantTransaction(it)
return
}
// M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails) // M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails)
if (selectedAccount?.bank == "MFAISA") { if (selectedAccount?.bank == "MFAISA") {
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty()) mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
@@ -1219,8 +1211,6 @@ class TransferFragment : Fragment() {
val destDisplay = binding.tvToAccountName.text?.toString() ?: resolvedAccountNumber val destDisplay = binding.tvToAccountName.text?.toString() ?: resolvedAccountNumber
val bankNameCapture = resolvedBankName val bankNameCapture = resolvedBankName
val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
// The MIB receipt only takes a real photo; it draws its own initials placeholder otherwise
val capturedToPhoto = capturedToAvatar?.takeIf { it === loadedToPhoto }
val destCurrency = resolvedDestCurrency.ifBlank { val destCurrency = resolvedDestCurrency.ifBlank {
allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber } allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber }
@@ -1258,7 +1248,7 @@ class TransferFragment : Fragment() {
val activity = requireActivity() as HomeActivity val activity = requireActivity() as HomeActivity
activity.triggerRefresh() activity.triggerRefresh()
dialog.dismiss() dialog.dismiss()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToPhoto)) showReceipt(receipt, capturedToAvatar)
} else if (!ok) { } else if (!ok) {
dialog.dismiss() dialog.dismiss()
if (msg == "CONNECTIVITY") { if (msg == "CONNECTIVITY") {
@@ -1634,7 +1624,6 @@ class TransferFragment : Fragment() {
if (_binding != null) { if (_binding != null) {
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(bitmap) binding.ivToPhoto.setImageBitmap(bitmap)
loadedToPhoto = bitmap
} }
} }
} }
@@ -14,8 +14,6 @@ data class TransferReceiptData(
// MIB receipt fields // MIB receipt fields
val mibReferenceNo: String = "", val mibReferenceNo: String = "",
val mibTransactionDate: String = "", val mibTransactionDate: String = "",
val mibFromProfileName: String = "",
val mibTransactionType: String = "", // "Own Transfer", "MIB Transfer", "Quick Transfer"
// BML receipt fields // BML receipt fields
val bmlFromName: String = "", val bmlFromName: String = "",
val bmlReference: String = "", val bmlReference: String = "",
@@ -24,7 +24,6 @@ import android.widget.Toast
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
@@ -34,8 +33,6 @@ 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.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.databinding.DialogReceiptFullscreenBmlBinding
import sh.sar.basedbank.databinding.DialogReceiptFullscreenMibBinding
import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding
import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
@@ -64,8 +61,6 @@ class TransferReceiptFragment : Fragment() {
private const val ARG_REMARKS = "remarks" private const val ARG_REMARKS = "remarks"
private const val ARG_MIB_REF = "mib_ref" private const val ARG_MIB_REF = "mib_ref"
private const val ARG_MIB_DATE = "mib_date" private const val ARG_MIB_DATE = "mib_date"
private const val ARG_MIB_FROM_PROFILE = "mib_from_profile"
private const val ARG_MIB_TXN_TYPE = "mib_txn_type"
private const val ARG_BML_FROM_NAME = "bml_from_name" private const val ARG_BML_FROM_NAME = "bml_from_name"
private const val ARG_BML_REFERENCE = "bml_reference" private const val ARG_BML_REFERENCE = "bml_reference"
private const val ARG_BML_TIMESTAMP = "bml_timestamp" private const val ARG_BML_TIMESTAMP = "bml_timestamp"
@@ -94,8 +89,6 @@ class TransferReceiptFragment : Fragment() {
putString(ARG_REMARKS, data.remarks) putString(ARG_REMARKS, data.remarks)
putString(ARG_MIB_REF, data.mibReferenceNo) putString(ARG_MIB_REF, data.mibReferenceNo)
putString(ARG_MIB_DATE, data.mibTransactionDate) putString(ARG_MIB_DATE, data.mibTransactionDate)
putString(ARG_MIB_FROM_PROFILE, data.mibFromProfileName)
putString(ARG_MIB_TXN_TYPE, data.mibTransactionType)
putString(ARG_BML_FROM_NAME, data.bmlFromName) putString(ARG_BML_FROM_NAME, data.bmlFromName)
putString(ARG_BML_REFERENCE, data.bmlReference) putString(ARG_BML_REFERENCE, data.bmlReference)
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp) putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
@@ -168,15 +161,6 @@ class TransferReceiptFragment : Fragment() {
view.findViewById<MaterialButton>(R.id.btnSave).setOnClickListener { view.findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
saveReceipt() saveReceipt()
} }
val alwaysFullScreen = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getBoolean("always_fullscreen_receipt", false)
if (alwaysFullScreen) {
// The normal page is skipped: keep it laid out (share/save capture its card) but hidden,
// and leave the receipt entirely when the full-screen view is closed
view.alpha = 0f
view.post { if (_receiptCard != null) showFullScreenReceipt(closePageOnDismiss = true) }
}
} }
// ── Data binding ────────────────────────────────────────────────────────── // ── Data binding ──────────────────────────────────────────────────────────
@@ -184,64 +168,43 @@ class TransferReceiptFragment : Fragment() {
private fun bindMib(binding: FragmentReceiptMibBinding) { private fun bindMib(binding: FragmentReceiptMibBinding) {
val args = requireArguments() val args = requireArguments()
val fromLabel = args.getString(ARG_FROM_LABEL, "") val fromLabel = args.getString(ARG_FROM_LABEL, "")
val fromColor = args.getString(ARG_FROM_COLOR, "#FE860E")
val fromProfileHash = args.getString(ARG_FROM_PROFILE_HASH) val fromProfileHash = args.getString(ARG_FROM_PROFILE_HASH)
val toLabel = args.getString(ARG_TO_LABEL, "") val toLabel = args.getString(ARG_TO_LABEL, "")
val currency = args.getString(ARG_CURRENCY, "MVR") val currency = args.getString(ARG_CURRENCY, "MVR")
val amount = args.getString(ARG_AMOUNT, "") val amount = args.getString(ARG_AMOUNT, "")
// From avatar: initials first, then load profile image if hash available // From avatar: initials first, then load profile image if hash available
binding.ivFromAvatar.setImageBitmap(makeMibInitialsBitmap(fromLabel)) binding.ivFromAvatar.setImageBitmap(makeInitialsBitmap(fromLabel, fromColor))
binding.tvFromLabel.text = fromLabel binding.tvFromLabel.text = fromLabel
if (fromProfileHash != null) { if (fromProfileHash != null) {
loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(circleCrop(it)) } loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(it) }
} }
// To avatar: use already-rendered bitmap from TransferFragment if available // To avatar: use already-rendered bitmap from TransferFragment if available
val toAvatar = pendingToAvatarBitmap val toAvatar = pendingToAvatarBitmap
if (toAvatar != null) { if (toAvatar != null) {
binding.ivToAvatar.setImageBitmap(circleCrop(toAvatar)) binding.ivToAvatar.setImageBitmap(toAvatar)
} else { } else {
binding.ivToAvatar.setImageBitmap(makeMibInitialsBitmap(toLabel)) binding.ivToAvatar.setImageBitmap(makeInitialsBitmap(toLabel, "#607D8B"))
} }
binding.tvToLabel.text = toLabel binding.tvToLabel.text = toLabel
binding.tvAmount.text = "$currency $amount" binding.tvAmount.text = "$currency $amount"
val toBank = args.getString(ARG_TO_BANK, "")
val rawDate = args.getString(ARG_MIB_DATE, "")
binding.tvReferenceNo.text = args.getString(ARG_MIB_REF, "") binding.tvReferenceNo.text = args.getString(ARG_MIB_REF, "")
binding.tvFromName.text = args.getString(ARG_MIB_FROM_PROFILE, "").ifBlank { fromLabel } binding.tvToAccount.text = args.getString(ARG_TO_ACCOUNT, "")
binding.tvToAccount.text = listOf(toLabel, args.getString(ARG_TO_ACCOUNT, "")) binding.tvToBank.text = args.getString(ARG_TO_BANK, "")
.filter { it.isNotBlank() }.joinToString("\n") binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
binding.tvToBank.text = toBank binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
// Receipts saved before the type was recorded fall back to a guess from the bank
binding.tvTransactionType.text = args.getString(ARG_MIB_TXN_TYPE, "").ifBlank {
if (toBank == "MIB") "MIB Transfer" else "Quick Transfer"
}
binding.tvTransactionDate.text = formatMibDate(rawDate, "dd MMM yyyy HH:mm")
binding.tvValueDate.text = formatMibDate(rawDate, "dd MMM yyyy")
binding.tvPurpose.text = args.getString(ARG_REMARKS, "") binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
.takeUnless { it.isNullOrBlank() || it.trim() == "-" } ?: "N/A"
copyOnLongClick( copyOnLongClick(
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount, binding.tvStatus, binding.tvFromLabel, binding.tvToLabel, binding.tvAmount,
binding.tvReferenceNo, binding.tvFromName, binding.tvToAccount, binding.tvToBank, binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank,
binding.tvTransactionType, binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
) )
} }
/** Reformats the MIB transfer date ("2026-05-16 15:10:25") to [pattern]; raw text if unparseable. */
private fun formatMibDate(raw: String, pattern: String): String {
if (raw.isBlank()) return ""
val out = DateTimeFormatter.ofPattern(pattern, Locale.US)
for (inPattern in listOf("yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "dd MMM yyyy HH:mm")) {
try {
return java.time.LocalDateTime.parse(raw.trim(), DateTimeFormatter.ofPattern(inPattern, Locale.US)).format(out)
} catch (_: Exception) { }
}
return raw
}
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) { private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val sess = app.anyMibSession() ?: return val sess = app.anyMibSession() ?: return
@@ -412,13 +375,8 @@ class TransferReceiptFragment : Fragment() {
* applied to fit small viewports and doesn't pick up overlapping siblings. * applied to fit small viewports and doesn't pick up overlapping siblings.
*/ */
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) { private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
val shown = _receiptCard ?: run { callback(null); return } val view = _receiptCard ?: run { callback(null); return }
if (shown.width == 0 || shown.height == 0) { callback(null); return } if (view.width == 0 || view.height == 0) { callback(null); return }
// BML: the preview follows the app theme, but shared/saved images are always light
val view = if (arguments?.getString(ARG_BANK, "MIB") == "BML") {
inflateLightBmlCard(shown.width)
} else shown
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap) val canvas = Canvas(bitmap)
@@ -427,27 +385,6 @@ class TransferReceiptFragment : Fragment() {
callback(bitmap) callback(bitmap)
} }
/** Inflates and lays out an offscreen BML receipt card with light-mode resources. */
private fun inflateLightBmlCard(widthPx: Int): View {
val config = android.content.res.Configuration(resources.configuration).apply {
uiMode = (uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK.inv()) or
android.content.res.Configuration.UI_MODE_NIGHT_NO
}
val lightCtx = android.view.ContextThemeWrapper(requireContext(), R.style.Theme_BasedBank).apply {
applyOverrideConfiguration(config)
}
val binding = FragmentReceiptBmlBinding.inflate(LayoutInflater.from(lightCtx))
bindBml(binding)
val card = binding.receiptCard
(card.parent as? ViewGroup)?.removeView(card)
card.measure(
View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
card.layout(0, 0, card.measuredWidth, card.measuredHeight)
return card
}
private fun formatBmlTimestamp(raw: String): String { private fun formatBmlTimestamp(raw: String): String {
if (raw.isBlank()) return "" if (raw.isBlank()) return ""
return try { return try {
@@ -457,46 +394,26 @@ class TransferReceiptFragment : Fragment() {
} }
} }
/** Center-crops [src] to a square and masks it to a circle. */ private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
private fun circleCrop(src: Bitmap): Bitmap {
val size = minOf(src.width, src.height)
val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
shader = android.graphics.BitmapShader(src, android.graphics.Shader.TileMode.CLAMP, android.graphics.Shader.TileMode.CLAMP).apply {
setLocalMatrix(android.graphics.Matrix().apply {
setTranslate(-(src.width - size) / 2f, -(src.height - size) / 2f)
})
}
}
Canvas(out).drawCircle(size / 2f, size / 2f, size / 2f, paint)
return out
}
/** MIB receipt placeholder: up to two initials in #1168F3 on a #C6E1FD circle. */
private fun makeMibInitialsBitmap(name: String): Bitmap {
val sizePx = (resources.displayMetrics.density * 52).toInt() val sizePx = (resources.displayMetrics.density * 52).toInt()
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm) val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG) val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = Color.parseColor("#C6E1FD") paint.color = bgColor
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint) canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
paint.color = Color.parseColor("#1168F3") paint.color = Color.WHITE
paint.textSize = sizePx * 0.36f paint.textSize = sizePx * 0.42f
paint.textAlign = Paint.Align.CENTER paint.textAlign = Paint.Align.CENTER
paint.typeface = android.graphics.Typeface.DEFAULT_BOLD val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val initials = name.split(Regex("\\s+"))
.mapNotNull { word -> word.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar() }
.take(2).joinToString("").ifEmpty { "?" }
val metrics = paint.fontMetrics val metrics = paint.fontMetrics
canvas.drawText(initials, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint) canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
return bm return bm
} }
private fun showFullScreenReceipt(closePageOnDismiss: Boolean = false) { private fun showFullScreenReceipt() {
val ctx = requireContext() val ctx = requireContext()
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB" val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
if (bank == "BML") { showBmlFullScreenReceipt(closePageOnDismiss); return }
if (bank == "MIB") { showMibFullScreenReceipt(closePageOnDismiss); return }
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen) val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
val scrollView = android.widget.ScrollView(ctx).apply { val scrollView = android.widget.ScrollView(ctx).apply {
@@ -538,7 +455,6 @@ class TransferReceiptFragment : Fragment() {
android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_NO android.content.res.Configuration.UI_MODE_NIGHT_NO
insetsCtrl.isAppearanceLightStatusBars = isLight insetsCtrl.isAppearanceLightStatusBars = isLight
if (closePageOnDismiss) closeReceiptPage()
} }
dialog.show() dialog.show()
dialog.window?.let { win -> dialog.window?.let { win ->
@@ -550,121 +466,6 @@ class TransferReceiptFragment : Fragment() {
} }
} }
/**
* BML full-screen receipt: status bar stays visible, top bar with back button,
* edge-to-edge card right under it, BML-styled Save/Share buttons directly below the card.
* Follows the app theme (light/dark).
*/
private fun showBmlFullScreenReceipt(closePageOnDismiss: Boolean) {
val ctx = requireContext()
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
val page = DialogReceiptFullscreenBmlBinding.inflate(layoutInflater)
val card = FragmentReceiptBmlBinding.inflate(layoutInflater).also { bindBml(it) }.receiptCard
(card.parent as? ViewGroup)?.removeView(card)
page.cardHolder.addView(card, 0, android.widget.LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
))
page.btnBack.setOnClickListener { dialog.dismiss() }
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
page.btnSaveFull.setOnClickListener { saveReceipt() }
page.btnShareFull.setOnClickListener { shareReceipt() }
val topBasePadding = page.topBar.paddingTop
val bottomBasePadding = page.bottomBar.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(page.root) { _, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
page.topBar.updatePadding(top = topBasePadding + bars.top)
page.bottomBar.updatePadding(bottom = bottomBasePadding + bars.bottom)
page.root.updatePadding(left = bars.left, right = bars.right)
insets
}
dialog.setContentView(page.root)
dialog.window?.let { win ->
win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
@Suppress("DEPRECATION")
win.statusBarColor = Color.TRANSPARENT
@Suppress("DEPRECATION")
win.navigationBarColor = Color.TRANSPARENT
val isLight = (resources.configuration.uiMode and
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_NO
androidx.core.view.WindowInsetsControllerCompat(win, win.decorView).apply {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
}
dialog.show()
}
/**
* MIB full-screen receipt: edge-to-edge card whose green header runs under the
* (visible) status bar, a floating close button top-right just below the status bar,
* and MIB-styled Share/Save buttons pinned to the bottom. Follows the app theme.
*/
private fun showMibFullScreenReceipt(closePageOnDismiss: Boolean) {
val ctx = requireContext()
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
val page = DialogReceiptFullscreenMibBinding.inflate(layoutInflater)
val cardBinding = FragmentReceiptMibBinding.inflate(layoutInflater).also { bindMib(it) }
val card = cardBinding.receiptCard
(card.parent as? ViewGroup)?.removeView(card)
page.cardHolder.addView(card, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
))
page.btnClose.setOnClickListener { dialog.dismiss() }
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
page.btnShareFull.setOnClickListener { shareReceipt() }
page.btnSaveFull.setOnClickListener { saveReceipt() }
val header = cardBinding.receiptHeader
val headerBaseHeight = header.layoutParams.height
val headerBasePadding = header.paddingTop
val closeBaseMargin = (page.btnClose.layoutParams as ViewGroup.MarginLayoutParams).topMargin
val bottomBasePadding = page.bottomBar.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(page.root) { _, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
// Grow the green header under the status bar, keeping its content below it
header.layoutParams = header.layoutParams.apply { height = headerBaseHeight + bars.top }
header.updatePadding(top = headerBasePadding + bars.top)
page.btnClose.layoutParams = (page.btnClose.layoutParams as ViewGroup.MarginLayoutParams)
.apply { topMargin = closeBaseMargin + bars.top }
page.bottomBar.updatePadding(bottom = bottomBasePadding + bars.bottom)
page.root.updatePadding(left = bars.left, right = bars.right)
insets
}
dialog.setContentView(page.root)
dialog.window?.let { win ->
win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
@Suppress("DEPRECATION")
win.statusBarColor = Color.TRANSPARENT
@Suppress("DEPRECATION")
win.navigationBarColor = Color.TRANSPARENT
val isLight = (resources.configuration.uiMode and
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_NO
androidx.core.view.WindowInsetsControllerCompat(win, win.decorView).apply {
// Status bar sits over the green header, so always use light icons
isAppearanceLightStatusBars = false
isAppearanceLightNavigationBars = isLight
}
}
dialog.show()
}
/** Pops this receipt off the back stack, returning to whatever screen opened it. */
private fun closeReceiptPage() {
if (!isAdded || parentFragmentManager.isStateSaved) return
parentFragmentManager.popBackStack()
}
private fun copyOnLongClick(vararg views: android.widget.TextView) { private fun copyOnLongClick(vararg views: android.widget.TextView) {
for (tv in views) { for (tv in views) {
tv.setOnLongClickListener { tv.setOnLongClickListener {
@@ -0,0 +1,62 @@
package sh.sar.basedbank.ui.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import sh.sar.basedbank.databinding.SheetTransferBinding
/**
* Shows [TransferFragment] in a bottom sheet so the screen behind (a chat) stays visible.
* The transfer page runs unchanged inside; with returnOnSuccess it dismisses this sheet.
*/
class TransferSheetFragment : BottomSheetDialogFragment() {
private var _binding: SheetTransferBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = SheetTransferBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// Leave the top of the chat visible above the sheet.
binding.transferContainer.layoutParams.height = (resources.displayMetrics.heightPixels * SHEET_HEIGHT_FRACTION).toInt()
if (childFragmentManager.findFragmentById(binding.transferContainer.id) == null) {
val transferArgs = requireArguments()
childFragmentManager.beginTransaction()
.replace(binding.transferContainer.id, TransferFragment().apply { arguments = transferArgs })
.commitNow()
}
}
override fun onStart() {
super.onStart()
(dialog as? BottomSheetDialog)?.let { d ->
// Keep the amount/remarks fields above the keyboard, but don't open it by itself.
@Suppress("DEPRECATION")
d.window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN
)
d.behavior.skipCollapsed = true
d.behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
private const val SHEET_HEIGHT_FRACTION = 0.8f
/** [transferArgs] are the arguments of a TransferFragment built by one of its factories. */
fun newInstance(transferArgs: Bundle) = TransferSheetFragment().apply { arguments = transferArgs }
}
}
@@ -351,6 +351,7 @@ class BmlTransferHandler(
) { ) {
fragment.clearForm() fragment.clearForm()
host?.triggerRefresh() host?.triggerRefresh()
fragment.returnToCallerIfRequested()
} }
} else { } else {
dialog.dismiss() dialog.dismiss()
@@ -129,15 +129,6 @@ class MibTransferHandler(
else -> bankName.ifBlank { "LOCAL" } else -> bankName.ifBlank { "LOCAL" }
} }
} }
val isOwnAccount = isDestMib && app.mibAccounts.any {
it.accountNumber == destAccount && it.loginTag == src.loginTag &&
(src.profileId.isBlank() || it.profileId == src.profileId)
}
val transactionType = when {
isOwnAccount -> "Own Transfer"
isDestMib -> "MIB Transfer"
else -> "Quick Transfer"
}
return try { return try {
// Switch to the profile that owns the source account // Switch to the profile that owns the source account
if (src.profileId.isNotBlank()) { if (src.profileId.isNotBlank()) {
@@ -169,9 +160,7 @@ class MibTransferHandler(
toBank = toBank, toBank = toBank,
remarks = remarks, remarks = remarks,
mibReferenceNo = result.trxId, mibReferenceNo = result.trxId,
mibTransactionDate = result.date, mibTransactionDate = result.date
mibFromProfileName = src.profileName,
mibTransactionType = transactionType
) )
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt) Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
} else { } else {
@@ -129,8 +129,8 @@ class CredentialsFragment : Fragment() {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
} }
binding.cardOtp.root.setOnClickListener { binding.cardOtp.setOnClickListener {
val code = binding.cardOtp.tvOtpCode.text.toString().replace(" ", "") val code = binding.tvOtpCode.text.toString().replace(" ", "")
if (code.isNotEmpty()) { if (code.isNotEmpty()) {
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code)) clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
@@ -198,12 +198,12 @@ class CredentialsFragment : Fragment() {
val otpSeedRaw = binding.etOtpSeed.text.toString().trim() val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
val seed = resolveOtpSeed(otpSeedRaw) val seed = resolveOtpSeed(otpSeedRaw)
if (seed.isEmpty()) { if (seed.isEmpty()) {
binding.cardOtp.root.visibility = View.INVISIBLE binding.cardOtp.visibility = View.INVISIBLE
return return
} }
val password = binding.etPassword.text.toString() val password = binding.etPassword.text.toString()
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) { if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
binding.cardOtp.root.visibility = View.INVISIBLE binding.cardOtp.visibility = View.INVISIBLE
return return
} }
try { try {
@@ -211,13 +211,13 @@ class CredentialsFragment : Fragment() {
val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt() val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt()
val remaining = 30 - secondsInPeriod val remaining = 30 - secondsInPeriod
binding.cardOtp.tvOtpCode.text = otp binding.tvOtpCode.text = otp
binding.cardOtp.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1) binding.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
binding.cardOtp.otpTimer.max = 30 binding.otpTimer.max = 30
binding.cardOtp.otpTimer.progress = remaining binding.otpTimer.progress = remaining
binding.cardOtp.root.visibility = View.VISIBLE binding.cardOtp.visibility = View.VISIBLE
} catch (e: Exception) { } catch (e: Exception) {
binding.cardOtp.root.visibility = View.INVISIBLE binding.cardOtp.visibility = View.INVISIBLE
} }
} }
@@ -0,0 +1,85 @@
package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.bml.BmlValidateClient
import java.io.File
/**
* Encrypted directory of account number → real account-holder name and currency, looked up through BML's
* account validation. Saved contacts often carry a nickname, while bank history only shows the
* real name; this lets a name-only transfer be matched to the right account.
*/
object AccountNameStore {
private const val FILE_NAME = "account_names.json"
private val lock = Any()
/** Names are re-checked after this long, in case an account changes hands. */
private const val REFRESH_AFTER_MS = 30L * 24 * 60 * 60 * 1000
/** Failed lookups (closed or foreign accounts) are retried after this long. */
private const val RETRY_FAILED_AFTER_MS = 24L * 60 * 60 * 1000
/** Upper bound on lookups per call, so a big contact list is resolved over several syncs. */
private const val MAX_LOOKUPS_PER_RUN = 20
data class Info(val name: String, val currency: String)
private data class Entry(val name: String, val currency: String, val checkedAt: Long)
/** Account number → real name and currency, for every account resolved so far. */
fun all(context: Context): Map<String, Info> = synchronized(lock) {
load(context).filterValues { it.name.isNotBlank() }.mapValues { Info(it.value.name, it.value.currency) }
}
/** Looks up names for [accounts] that are unknown or stale. Network I/O — call on Dispatchers.IO. */
fun resolve(context: Context, session: BmlSession, accounts: Collection<String>) {
val now = System.currentTimeMillis()
val known = synchronized(lock) { load(context) }
val due = accounts.filter { it.isNotBlank() }.distinct().filter { account ->
val e = known[account] ?: return@filter true
val age = now - e.checkedAt
if (e.name.isBlank()) age > RETRY_FAILED_AFTER_MS else age > REFRESH_AFTER_MS
}.take(MAX_LOOKUPS_PER_RUN)
if (due.isEmpty()) return
val client = BmlValidateClient()
// null = network error: leave the account due for next time. A failed lookup is stored blank.
val found = due.associateWith { account ->
try {
val v = client.validateAccount(session, account)
Entry(v?.name?.trim().orEmpty(), v?.currency.orEmpty(), now)
} catch (_: Exception) { null }
}
synchronized(lock) {
val current = load(context).toMutableMap()
found.forEach { (account, entry) -> if (entry != null) current[account] = entry }
save(context, current)
}
}
fun clearAll(context: Context) = synchronized(lock) { File(context.filesDir, FILE_NAME).delete() }
private fun load(context: Context): Map<String, Entry> {
val file = File(context.filesDir, FILE_NAME)
if (!file.exists()) return emptyMap()
return try {
val root = JSONObject(CacheEncryption.decrypt(file.readText()))
root.keys().asSequence().associateWith { key ->
val o = root.getJSONObject(key)
Entry(o.optString("name"), o.optString("currency"), o.optLong("checkedAt", 0L))
}
} catch (_: Exception) { emptyMap() }
}
private fun save(context: Context, entries: Map<String, Entry>) {
try {
val root = JSONObject()
entries.forEach { (account, e) ->
root.put(account, JSONObject().put("name", e.name).put("currency", e.currency).put("checkedAt", e.checkedAt))
}
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
} catch (_: Exception) {}
}
}
@@ -0,0 +1,524 @@
package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.bml.BmlNotificationsClient
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.ui.home.AppNotification
import sh.sar.basedbank.ui.home.AccountHistoryAdapter
import java.io.File
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
/**
* One transfer shown as a chat bubble. BML history only carries the counterparty's name, so
* [peerKey] is the normalised name; [ChatStore.threads] groups by the account holder's real name.
*/
data class ChatMessage(
val id: String,
val peerKey: String,
val peerName: String,
val amount: Double, // negative = sent, positive = received
val currency: String,
val timeMillis: Long,
val accountNumber: String, // own account the money moved in/out of
val accountDisplayName: String,
val reference: String = "",
val note: String = "", // remarks — only known for transfers sent from this app
val peerAccount: String = "", // only known for transfers sent from this app
val receiptKey: String = "", // ReceiptStore entry (its savedAt) for transfers sent from this app
val dateOnly: Boolean = false, // bank gave only a date (Favara); time of day unknown
val merchant: Boolean = false // card / Scan to Pay payment to a business, not a person
) {
val isSent get() = amount < 0
/** In BML's account history (✓✓); otherwise only seen in a notification or receipt so far (✓). */
val isBooked get() = id.startsWith("bml_")
/** "yyyy-MM-dd HH:mm:ss", the format AccountHistoryAdapter's date helpers expect. */
val date: String get() = DATE_FMT.format(Date(timeMillis))
companion object {
private val DATE_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
}
}
/** One of a person's accounts that can be sent to from their chat. */
data class ChatAccount(
val account: String,
val currency: String, // "MVR", "USD"; blank if not known yet
val contact: BankContact? // saved contact for this account, if any
)
data class ChatThread(
val peerKey: String, // "name:<normalised real name>" or "shop:<normalised business name>"
val peerName: String, // contact nickname, else the real name
val realName: String, // account holder's name as the bank knows it; may be blank
val accounts: List<ChatAccount>, // empty = no known account, sending from the chat is disabled
val messages: List<ChatMessage>, // oldest first
val isMerchant: Boolean = false // a business paid by card / Scan to Pay; nothing to send to
) {
val last get() = messages.last()
val contact: BankContact? get() = accounts.firstNotNullOfOrNull { it.contact }
/** The account last sent to, else the first known one; blank when none is known. */
val peerAccount: String get() =
messages.lastOrNull { it.isSent && it.peerAccount.isNotBlank() }?.peerAccount
?.takeIf { a -> accounts.any { it.account == a } }
?: accounts.firstOrNull()?.account.orEmpty()
}
/**
* Encrypted local record of BML transfers and card / Scan to Pay payments, shown as chats.
*
* Old history is never loaded: the first call to [sync] only stamps the start time, and every
* later sync keeps transfers newer than it. Each sync reads BML's transfer notifications (instant),
* pages back through the latest history of every BML current/savings account (booked later), and
* merges in receipts of transfers sent from this app, keeping one message per transfer.
*/
object ChatStore {
private const val FILE_NAME = "chats.json"
private const val PINS_FILE_NAME = "chat_pins.json"
private const val QR_FILE_NAME = "chat_merchant_qr.json"
/** Prefix [RecentsCache] uses for static BML merchant QRs the user has paid. */
private const val RECENT_QR_PREFIX = "bmlqr:"
private val lock = Any()
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
private val TRANSFER_TYPES = setOf("Transfer Debit", "Transfer Credit", "Favara Debit", "Favara Credit")
/** Card and Scan to Pay payments; narrative2 is the business name. */
private val MERCHANT_TYPES = setOf("Purchase")
/** "You have received MVR 5.00 from NAME to 7730*****1234" */
private val NOTIF_RECEIVED = Regex("""You have received ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (.+) to (\S+)""")
/** "You have sent MVR 5.00 from 7730*****1234 to NAME" */
private val NOTIF_SENT = Regex("""You have sent ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (\S+) to (.+)""")
/** "You have paid MVR 5.00 from 4xxx********xxxx to BUSINESS" (Scan to Pay / card); the name may be cut short. */
private val NOTIF_PAID = Regex("""You have paid ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (\S+) to (.+)""")
/** Masked own account or card in notifications, e.g. "7730*****1234". */
private val MASKED_ACCOUNT = Regex("""(\d{3,})\*+(\d{3,})""")
/** How far apart two records of the same transfer can be timestamped. */
private const val MATCH_WINDOW_MS = 15 * 60 * 1000L
/** Upper bound on history pages fetched per account in one sync. */
private const val MAX_PAGES_PER_SYNC = 5
private const val NAME_KEY_PREFIX = "name:"
private const val MERCHANT_KEY_PREFIX = "shop:"
private data class State(val sinceMillis: Long, val messages: List<ChatMessage>)
fun normalise(name: String) = name.trim().replace(Regex("\\s+"), " ").lowercase(Locale.US)
fun sinceMillis(context: Context): Long = synchronized(lock) { load(context).sinceMillis }
fun isStarted(context: Context) = sinceMillis(context) > 0L
/**
* Groups stored messages into chats, most recent chat first. Call off the main thread.
*
* Chats are per person, keyed by the account holder's real name: bank history only names the
* sender, never their account, so money can't be told apart per account anyway. Every known
* account with that real name (saved contacts, accounts sent to) belongs to the chat, and the
* user picks which one to send to — typically their MVR or USD account.
*
* Real names come from [AccountNameStore], so a contact saved as "Ali" joins the
* "Mohamed Ali" chat.
*/
fun threads(context: Context, contacts: List<BankContact>): List<ChatThread> {
val messages = synchronized(lock) { load(context).messages }
val lookedUp = AccountNameStore.all(context)
val contactsByAccount = contacts.filter { it.benefAccount.isNotBlank() }.associateBy { it.benefAccount }
fun realNameOf(account: String): String? =
lookedUp[account]?.name?.takeIf { it.isNotBlank() }
?: contactsByAccount[account]?.benefName?.takeIf { it.isNotBlank() }
// Notifications cut business names short ("FAMILY ROOM" for "FAMILY ROOM COFFEE"); file a
// short name under the longest known name it starts.
val merchantNames = messages.filter { it.merchant }.map { it.peerKey }.toSet()
fun merchantKey(name: String) =
merchantNames.filter { it.startsWith(name) }.maxByOrNull { it.length } ?: name
fun keyOf(m: ChatMessage): String {
if (m.merchant) return MERCHANT_KEY_PREFIX + merchantKey(m.peerKey)
val name = m.peerAccount.takeIf { it.isNotBlank() }?.let(::realNameOf) ?: m.peerName
return NAME_KEY_PREFIX + normalise(name)
}
// Every known account, grouped under its holder's real name.
val accountsByKey = HashMap<String, LinkedHashMap<String, ChatAccount>>()
fun addAccount(account: String, fallbackCurrency: String) {
val name = realNameOf(account) ?: return
val contact = contactsByAccount[account]
val currency = lookedUp[account]?.currency?.takeIf { it.isNotBlank() }
?: contact?.transferCyDesc?.takeIf { it.isNotBlank() } ?: fallbackCurrency
accountsByKey.getOrPut(NAME_KEY_PREFIX + normalise(name)) { LinkedHashMap() }
.getOrPut(account) { ChatAccount(account, currency, contact) }
}
contactsByAccount.keys.forEach { addAccount(it, "") }
messages.filter { it.peerAccount.isNotBlank() }.forEach { addAccount(it.peerAccount, it.currency) }
return messages.groupBy(::keyOf).map { (key, msgs) ->
val sorted = msgs.sortedBy { it.timeMillis }
if (key.startsWith(MERCHANT_KEY_PREFIX)) {
// History carries the full business name; notifications may not.
val name = sorted.lastOrNull { it.id.startsWith("bml_") }?.peerName
?: sorted.maxByOrNull { it.peerName.length }!!.peerName
return@map ChatThread(key, name, "", emptyList(), sorted, isMerchant = true)
}
val accounts = accountsByKey[key]?.values?.toList().orEmpty()
// Bank history and the account lookup carry the real name; a receipt only has our label.
val realName = accounts.firstNotNullOfOrNull { lookedUp[it.account]?.name?.takeIf { n -> n.isNotBlank() } }
?: sorted.lastOrNull { !it.id.startsWith("rcpt_") }?.peerName
?: accounts.firstNotNullOfOrNull { it.contact?.benefName?.takeIf { n -> n.isNotBlank() } }
?: ""
val nickname = accounts.firstNotNullOfOrNull { it.contact?.benefNickName?.takeIf { n -> n.isNotBlank() } }
ChatThread(
peerKey = key,
peerName = nickname ?: realName.takeIf { it.isNotBlank() } ?: sorted.last().peerName,
realName = realName,
accounts = accounts,
messages = sorted
)
}.sortedByDescending { it.last.timeMillis }
}
/** Fetches new transfers and stores them. Does network and file I/O — call on Dispatchers.IO. */
suspend fun sync(context: Context, app: BasedBankApp, accounts: List<BankAccount>, contacts: List<BankContact>) {
val state = synchronized(lock) { load(context) }
if (state.sinceMillis == 0L) {
synchronized(lock) { save(context, State(System.currentTimeMillis(), emptyList())) }
return
}
val since = state.sinceMillis
val history = fetchHistory(app, accounts, since, state.messages.mapTo(HashSet()) { it.id })
val notifications = fetchNotifications(context, app, accounts, since)
val receipts = receiptMessages(context, accounts, since)
synchronized(lock) {
val current = load(context)
save(context, current.copy(messages = merge(current.messages, history + notifications + receipts)))
}
// Learn real names for contacts and people we've sent to, so name-only transfers match them.
val session = app.anyBmlSession() ?: return
val sentTo = synchronized(lock) { load(context).messages.map { it.peerAccount } }
// SWIFT (foreign) beneficiaries can't be looked up through BML.
val toResolve = contacts.filter { it.benefType != "S" }.map { it.benefAccount } + sentTo
AccountNameStore.resolve(context, session, toResolve)
}
/**
* Transfers from account history. BML only adds a transaction here once it is booked (often
* the next day), so recent ones come from [fetchNotifications] first.
*/
private suspend fun fetchHistory(
app: BasedBankApp,
accounts: List<BankAccount>,
since: Long,
knownIds: Set<String>
): List<ChatMessage> {
val fetched = mutableListOf<ChatMessage>()
for (account in accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }) {
val fetcher = HistoryFetcher(account)
var pages = 0
// Page back until we reach transfers we already have or ones older than the start time.
while (pages < MAX_PAGES_PER_SYNC && fetcher.hasMore()) {
val page = try { fetcher.fetchNextPage(app) } catch (_: Exception) { break }
pages++
if (page.isEmpty()) break
var reachedKnown = false
for (trx in page) {
val time = AccountHistoryAdapter.parseDateMillis(trx.date)
if (time in 1 until startOfDay(since) || "bml_${trx.id}" in knownIds) reachedKnown = true
trx.toChatMessage(time, since)?.let { fetched += it }
}
if (reachedKnown) break
}
}
return fetched
}
private fun sh.sar.basedbank.api.models.BankTransaction.toChatMessage(time: Long, since: Long): ChatMessage? {
val name = counterpartyName?.trim().orEmpty()
val merchant = description in MERCHANT_TYPES
if ((description !in TRANSFER_TYPES && !merchant) || name.isBlank()) return null
// Favara (other-bank) entries carry only a date; keep those from the start day onwards.
val dateOnly = date.contains("T00:00:00")
if (if (dateOnly) time < startOfDay(since) else time < since) return null
return ChatMessage(
id = "bml_$id",
peerKey = normalise(name),
peerName = name,
amount = amount,
currency = currency,
timeMillis = time,
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
reference = reference.orEmpty(),
dateOnly = dateOnly,
merchant = merchant
)
}
/**
* Transfers from BML's "Funds Received" / "Funds Transferred" notifications, which arrive
* instantly. Uses the notifications the app has cached plus a fresh first page; the fresh page
* is not written back to the cache, which the notification poller uses to spot new alerts.
*/
private fun fetchNotifications(
context: Context,
app: BasedBankApp,
accounts: List<BankAccount>,
since: Long
): List<ChatMessage> {
val client = BmlNotificationsClient()
val notifications = LinkedHashMap<String, AppNotification>()
for ((loginId, session) in app.bmlSessions.toMap()) {
NotificationsCache.loadBml(context, loginId).forEach { notifications[it.id] = it }
try { client.fetchNotifications(session, loginId, page = 1).items.forEach { notifications[it.id] = it } }
catch (_: Exception) {}
}
return notifications.values
.filter { it.timestampMs >= since }
.mapNotNull { notificationToMessage(it, accounts) }
}
private fun notificationToMessage(n: AppNotification, accounts: List<BankAccount>): ChatMessage? {
val text = n.message.trim()
val received = NOTIF_RECEIVED.matchEntire(text)
val sent = if (received == null) NOTIF_SENT.matchEntire(text) else null
val paid = if (received == null && sent == null) NOTIF_PAID.matchEntire(text) else null
val (currency, amountText, name, ownMasked) = when {
received != null -> received.destructured.let { (c, a, who, own) -> listOf(c, a, who, own) }
sent != null -> sent.destructured.let { (c, a, own, who) -> listOf(c, a, who, own) }
paid != null -> paid.destructured.let { (c, a, own, who) -> listOf(c, a, who, own) }
else -> return null
}
if (MASKED_ACCOUNT.matches(name.trim())) return null // between the user's own accounts
val amount = amountText.replace(",", "").toDoubleOrNull() ?: return null
val own = MASKED_ACCOUNT.matchEntire(ownMasked)?.let { m ->
val (head, tail) = m.destructured
accounts.firstOrNull { it.bank == "BML" && it.accountNumber.startsWith(head) && it.accountNumber.endsWith(tail) }
}
return ChatMessage(
id = "ntf_${n.id}",
peerKey = normalise(name),
peerName = name.trim(),
amount = if (received != null) amount else -amount,
currency = currency,
timeMillis = n.timestampMs,
accountNumber = own?.accountNumber.orEmpty(),
accountDisplayName = own?.accountBriefName ?: "•••• ${ownMasked.takeLast(4)}",
merchant = paid != null
)
}
/** Transfers sent from this app, from their saved receipts: these carry the note and slip. */
private fun receiptMessages(context: Context, accounts: List<BankAccount>, since: Long): List<ChatMessage> =
ReceiptStore.loadAll(context).mapNotNull { entry ->
val r = entry.data
if (r.bank != "BML" || entry.savedAt < since) return@mapNotNull null
val amount = r.amount.toDoubleOrNull() ?: return@mapNotNull null
val time = AccountHistoryAdapter.parseDateMillis(r.bmlTimestamp).takeIf { it > 0L } ?: entry.savedAt
val from = accounts.firstOrNull { it.bank == "BML" && it.accountBriefName == r.fromLabel }
ChatMessage(
id = "rcpt_${r.bmlReference.ifBlank { entry.savedAt.toString() }}",
peerKey = normalise(r.toLabel),
peerName = r.toLabel,
amount = -amount,
currency = r.currency,
timeMillis = time,
accountNumber = from?.accountNumber.orEmpty(),
accountDisplayName = r.fromLabel,
reference = r.bmlReference,
note = r.remarks,
peerAccount = r.toAccount,
receiptKey = entry.savedAt.toString()
)
}
/**
* Merges stored and newly fetched messages so each transfer appears once. The same transfer can
* come from history, a notification and a receipt; the highest-ranked source is kept (history,
* then notification, then receipt) and picks up what the others add: the exact time, the note,
* the recipient account and the receipt.
*/
private fun merge(existing: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
val byId = LinkedHashMap<String, ChatMessage>()
incoming.forEach { byId[it.id] = it }
existing.forEach { byId[it.id] = it } // stored copies already carry merged details
val kept = mutableListOf<ChatMessage>()
val absorbed = HashMap<String, MutableSet<Int>>() // kept id -> source ranks merged into it
for (candidate in byId.values.sortedWith(compareBy({ rank(it) }, { it.timeMillis }))) {
val r = rank(candidate)
val index = kept.indexOfFirst { k ->
rank(k) < r && r !in absorbed[k.id].orEmpty() && sameTransfer(k, candidate)
}
if (index >= 0) {
val k = kept[index]
kept[index] = k.copy(
timeMillis = if (k.dateOnly && !candidate.dateOnly) candidate.timeMillis else k.timeMillis,
dateOnly = k.dateOnly && candidate.dateOnly,
accountNumber = k.accountNumber.ifBlank { candidate.accountNumber },
reference = k.reference.ifBlank { candidate.reference },
note = k.note.ifBlank { candidate.note },
peerAccount = k.peerAccount.ifBlank { candidate.peerAccount },
receiptKey = k.receiptKey.ifBlank { candidate.receiptKey },
merchant = k.merchant || candidate.merchant
)
absorbed.getOrPut(k.id) { mutableSetOf() } += r
} else {
kept += candidate
}
}
return kept.sortedBy { it.timeMillis }
}
private fun rank(m: ChatMessage) = when {
m.id.startsWith("bml_") -> 0
m.id.startsWith("ntf_") -> 1
else -> 2
}
private fun sameTransfer(a: ChatMessage, b: ChatMessage): Boolean {
if (a.reference.isNotBlank() && a.reference == b.reference) return true
if (a.isSent != b.isSent || kotlin.math.abs(a.amount - b.amount) >= 0.005) return false
// A payment to a business is never the same as a transfer to a person (receipts aren't tagged).
val receipt = a.id.startsWith("rcpt_") || b.id.startsWith("rcpt_")
if (!receipt && a.merchant != b.merchant) return false
if (a.currency.isNotBlank() && b.currency.isNotBlank() && !a.currency.equals(b.currency, ignoreCase = true)) return false
if (a.accountNumber.isNotBlank() && b.accountNumber.isNotBlank() && a.accountNumber != b.accountNumber) return false
if (a.dateOnly || b.dateOnly) {
// A date-only entry could be any transfer that day, so the names must agree too.
if (startOfDay(a.timeMillis) != startOfDay(b.timeMillis)) return false
return receipt || a.peerKey == b.peerKey || a.peerKey.contains(b.peerKey) || b.peerKey.contains(a.peerKey)
}
return kotlin.math.abs(a.timeMillis - b.timeMillis) <= MATCH_WINDOW_MS
}
private fun startOfDay(millis: Long): Long = Calendar.getInstance().apply {
timeInMillis = millis
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
}.timeInMillis
fun clearAll(context: Context) = synchronized(lock) {
File(context.filesDir, FILE_NAME).delete()
File(context.filesDir, PINS_FILE_NAME).delete()
File(context.filesDir, QR_FILE_NAME).delete()
}
// ─── Saved merchant QRs ───────────────────────────────────────────────────
/**
* The static QR (no preset amount) last paid at the business named [merchantName], or null.
* The app already remembers such QRs among its recents, but only the last few; they are
* copied here so a business's QR is kept for good. Call off the main thread.
*/
fun merchantQr(context: Context, merchantName: String): String? = synchronized(lock) {
val file = File(context.filesDir, QR_FILE_NAME)
val saved = try {
if (!file.exists()) JSONObject() else JSONObject(CacheEncryption.decrypt(file.readText()))
} catch (_: Exception) { JSONObject() }
var changed = false
for (recent in RecentsCache.load(context)) {
if (!recent.accountNumber.startsWith(RECENT_QR_PREFIX)) continue
val key = normalise(recent.displayName)
val url = recent.accountNumber.removePrefix(RECENT_QR_PREFIX)
if (key.isNotBlank() && saved.optString(key) != url) { saved.put(key, url); changed = true }
}
if (changed) try { file.writeText(CacheEncryption.encrypt(saved.toString())) } catch (_: Exception) {}
// Names differ slightly between the QR, history and alerts; accept one starting the other.
val name = normalise(merchantName)
val keys = saved.keys().asSequence().toList()
val match = keys.firstOrNull { it == name }
?: keys.filter { it.startsWith(name) || name.startsWith(it) }.maxByOrNull { it.length }
match?.let { saved.optString(it).takeIf { url -> url.isNotBlank() } }
}
// ─── Pinned chats ─────────────────────────────────────────────────────────
/** Pinned chat keys, most recently pinned first. */
fun pins(context: Context): List<String> = synchronized(lock) {
val file = File(context.filesDir, PINS_FILE_NAME)
if (!file.exists()) return emptyList()
try {
val arr = JSONArray(CacheEncryption.decrypt(file.readText()))
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
}
fun setPinned(context: Context, peerKey: String, pinned: Boolean) {
val current = pins(context).filter { it != peerKey }
val updated = if (pinned) listOf(peerKey) + current else current
synchronized(lock) {
try {
File(context.filesDir, PINS_FILE_NAME)
.writeText(CacheEncryption.encrypt(JSONArray(updated).toString()))
} catch (_: Exception) {}
}
}
private fun load(context: Context): State {
val file = File(context.filesDir, FILE_NAME)
if (!file.exists()) return State(0L, emptyList())
return try {
val root = JSONObject(CacheEncryption.decrypt(file.readText()))
val arr = root.optJSONArray("messages") ?: JSONArray()
State(
sinceMillis = root.optLong("since", 0L),
messages = (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
ChatMessage(
id = o.optString("id"),
peerKey = o.optString("peerKey"),
peerName = o.optString("peerName"),
amount = o.optDouble("amount", 0.0),
currency = o.optString("currency"),
timeMillis = o.optLong("time", 0L),
accountNumber = o.optString("account"),
accountDisplayName = o.optString("accountName"),
reference = o.optString("reference"),
note = o.optString("note"),
peerAccount = o.optString("peerAccount"),
receiptKey = o.optString("receiptKey"),
dateOnly = o.optBoolean("dateOnly", false),
merchant = o.optBoolean("merchant", false)
)
}
)
} catch (_: Exception) { State(0L, emptyList()) }
}
private fun save(context: Context, state: State) {
try {
val arr = JSONArray()
for (m in state.messages) arr.put(JSONObject().apply {
put("id", m.id)
put("peerKey", m.peerKey)
put("peerName", m.peerName)
put("amount", m.amount)
put("currency", m.currency)
put("time", m.timeMillis)
put("account", m.accountNumber)
put("accountName", m.accountDisplayName)
put("reference", m.reference)
put("note", m.note)
put("peerAccount", m.peerAccount)
put("receiptKey", m.receiptKey)
put("dateOnly", m.dateOnly)
put("merchant", m.merchant)
})
val root = JSONObject().put("since", state.sinceMillis).put("messages", arr)
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
} catch (_: Exception) {}
}
}
@@ -100,11 +100,6 @@ class CredentialStore(context: Context) {
.apply() .apply()
} }
/** Replaces only the stored TOTP seed; the old seed is overwritten and cannot be recovered. */
fun updateMibOtpSeed(loginId: String, otpSeed: String) {
prefs.edit().putString("mib_${loginId}_enc_otp_seed", encrypt(otpSeed, getOrCreateKey())).apply()
}
fun loadMibCredentials(loginId: String): MibCredentials? { fun loadMibCredentials(loginId: String): MibCredentials? {
val key = getOrCreateKey() val key = getOrCreateKey()
val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null
@@ -235,11 +230,6 @@ class CredentialStore(context: Context) {
.apply() .apply()
} }
/** Replaces only the stored TOTP seed; the old seed is overwritten and cannot be recovered. */
fun updateBmlOtpSeed(loginId: String, otpSeed: String) {
prefs.edit().putString("bml_${loginId}_enc_otp_seed", encrypt(otpSeed, getOrCreateKey())).apply()
}
fun loadBmlCredentials(loginId: String): BmlCredentials? { fun loadBmlCredentials(loginId: String): BmlCredentials? {
val key = getOrCreateKey() val key = getOrCreateKey()
val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null
@@ -13,27 +13,6 @@ object OtpauthParser {
else -> emptyList() else -> emptyList()
} }
/**
* Normalise user input into a bare Base32 secret: accepts an otpauth:// link or a raw
* secret with spaces/dashes. Returns null if the result isn't usable as a TOTP seed.
*/
fun resolveSecret(input: String): String? {
val raw = input.trim()
val secret = if (raw.startsWith("otpauth://")) Uri.parse(raw).getQueryParameter("secret") ?: return null else raw
val clean = secret.replace("\\s".toRegex(), "").replace("-", "").trimEnd('=').uppercase()
if (clean.isEmpty() || !clean.all { it in 'A'..'Z' || it in '2'..'7' }) return null
// Too short to be a real seed, e.g. a pasted 6-digit OTP code
if (clean.length < 8) return null
return clean
}
/**
* Build the shortest otpauth://totp link authenticator apps accept: just a label and the
* secret. SHA1, 6 digits and a 30s period are the spec defaults, so they're left out.
*/
fun buildUri(label: String, secret: String): String =
"otpauth://totp/" + Uri.encode(label) + "?secret=$secret"
private fun parseStandard(raw: String): OtpEntry? { private fun parseStandard(raw: String): OtpEntry? {
val uri = Uri.parse(raw) val uri = Uri.parse(raw)
val secret = uri.getQueryParameter("secret") ?: return null val secret = uri.getQueryParameter("secret") ?: return null
@@ -40,8 +40,6 @@ object ReceiptStore {
remarks = o.optString("remarks"), remarks = o.optString("remarks"),
mibReferenceNo = o.optString("mibReferenceNo"), mibReferenceNo = o.optString("mibReferenceNo"),
mibTransactionDate = o.optString("mibTransactionDate"), mibTransactionDate = o.optString("mibTransactionDate"),
mibFromProfileName = o.optString("mibFromProfileName"),
mibTransactionType = o.optString("mibTransactionType"),
bmlFromName = o.optString("bmlFromName"), bmlFromName = o.optString("bmlFromName"),
bmlReference = o.optString("bmlReference"), bmlReference = o.optString("bmlReference"),
bmlTimestamp = o.optString("bmlTimestamp"), bmlTimestamp = o.optString("bmlTimestamp"),
@@ -78,8 +76,6 @@ object ReceiptStore {
put("remarks", d.remarks) put("remarks", d.remarks)
put("mibReferenceNo", d.mibReferenceNo) put("mibReferenceNo", d.mibReferenceNo)
put("mibTransactionDate", d.mibTransactionDate) put("mibTransactionDate", d.mibTransactionDate)
put("mibFromProfileName", d.mibFromProfileName)
put("mibTransactionType", d.mibTransactionType)
put("bmlFromName", d.bmlFromName) put("bmlFromName", d.bmlFromName)
put("bmlReference", d.bmlReference) put("bmlReference", d.bmlReference)
put("bmlTimestamp", d.bmlTimestamp) put("bmlTimestamp", d.bmlTimestamp)
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Dark-mode replacement for drawable/bottom_receipt_wave.jpg (988x48):
receipt background above the teeth, footer colour below. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="329dp"
android:height="16dp"
android:viewportWidth="988"
android:viewportHeight="48">
<path
android:fillColor="@color/bml_receipt_bg"
android:pathData="M0,0 L988,0 L988,48 L0,48 Z" />
<path
android:fillColor="@color/bml_receipt_footer"
android:pathData="M0,48 L0,27 L24.08,8.28 Q30.88,3 37.67,8.28 L54.96,21.72 Q61.75,27 68.54,21.72 L85.83,8.28 Q92.63,3 99.42,8.28 L116.71,21.72 Q123.5,27 130.29,21.72 L147.58,8.28 Q154.38,3 161.17,8.28 L178.46,21.72 Q185.25,27 192.04,21.72 L209.33,8.28 Q216.13,3 222.92,8.28 L240.21,21.72 Q247,27 253.79,21.72 L271.08,8.28 Q277.88,3 284.67,8.28 L301.96,21.72 Q308.75,27 315.54,21.72 L332.83,8.28 Q339.63,3 346.42,8.28 L363.71,21.72 Q370.5,27 377.29,21.72 L394.58,8.28 Q401.38,3 408.17,8.28 L425.46,21.72 Q432.25,27 439.04,21.72 L456.33,8.28 Q463.13,3 469.92,8.28 L487.21,21.72 Q494,27 500.79,21.72 L518.08,8.28 Q524.88,3 531.67,8.28 L548.96,21.72 Q555.75,27 562.54,21.72 L579.83,8.28 Q586.63,3 593.42,8.28 L610.71,21.72 Q617.5,27 624.29,21.72 L641.58,8.28 Q648.38,3 655.17,8.28 L672.46,21.72 Q679.25,27 686.04,21.72 L703.33,8.28 Q710.13,3 716.92,8.28 L734.21,21.72 Q741,27 747.79,21.72 L765.08,8.28 Q771.88,3 778.67,8.28 L795.96,21.72 Q802.75,27 809.54,21.72 L826.83,8.28 Q833.63,3 840.42,8.28 L857.71,21.72 Q864.5,27 871.29,21.72 L888.58,8.28 Q895.38,3 902.17,8.28 L919.46,21.72 Q926.25,27 933.04,21.72 L950.33,8.28 Q957.13,3 963.92,8.28 L988,27 L988,48 Z" />
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="10dp" />
<stroke android:width="1dp" android:color="?attr/colorOutline" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="14dp" />
<solid android:color="?attr/colorSurfaceContainerHigh" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:startColor="#2E7D32"
android:centerColor="#43A047"
android:endColor="#66BB6A"
android:angle="315"
android:type="linear" />
</shape>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

+9
View File
@@ -0,0 +1,9 @@
<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="M20,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,9h12v2H6V9zM14,14H6v-2h8v2zM18,8H6V6h12v2z"/>
</vector>
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- MIB full-screen receipt close button: tinted-black disc, themed ring, white X -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:fillColor="#4D000000"
android:strokeColor="@color/mib_receipt_close_ring"
android:strokeWidth="1.5"
android:pathData="M16,1.25 A14.75,14.75 0 1,1 16,30.75 A14.75,14.75 0 1,1 16,1.25 Z" />
<path
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:pathData="M11,11 L21,21 M21,11 L11,21" />
</vector>
+9
View File
@@ -0,0 +1,9 @@
<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="M16,12V4h1V2H7v2h1v8l-2,2v2h5.2v6h1.6v-6H18v-2l-2,-2z" />
</vector>
+9
View File
@@ -0,0 +1,9 @@
<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="M19.5,3.5L18,2l-1.5,1.5L15,2l-1.5,1.5L12,2l-1.5,1.5L9,2 7.5,3.5 6,2 4.5,3.5 3,2v20l1.5,-1.5L6,22l1.5,-1.5L9,22l1.5,-1.5L12,22l1.5,-1.5L15,22l1.5,-1.5L18,22l1.5,-1.5L21,22V2l-1.5,1.5zM19,19.09H5V4.91h14v14.18zM6,15h12v2H6zM6,11h12v2H6zM6,7h12v2H6z"/>
</vector>
@@ -1,13 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path
android:fillColor="@android:color/transparent"
android:strokeColor="#FFFFFFFF" android:strokeColor="#FFFFFFFF"
android:strokeWidth="2" android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeLineJoin="round"
android:pathData="M15,4 L7,12 L15,20" /> android:pathData="M4,9l1.5,-5h13L20,9M4,9v11h16V9M4,9h16M9,20v-6h6v6" />
</vector> </vector>
@@ -1,77 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Full MIB logo (mark + "MALDIVES ISLAMIC BANK" wordmark); wordmark follows the receipt footer text color -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="187.6dp"
android:height="22dp"
android:viewportWidth="779.5"
android:viewportHeight="91.4">
<path
android:fillColor="#FFFFFF"
android:strokeColor="#A8AAAC"
android:strokeWidth="3.8833"
android:strokeMiterLimit="10"
android:pathData="M64.6,89.2H26.7c-13.5,0-24.5-11-24.5-24.5v-38c0-13.5,11-24.5,24.5-24.5h37.9c13.5,0,24.5,11,24.5,24.5v37.9 C89.1,78.2,78.2,89.2,64.6,89.2z" />
<path
android:fillColor="#1E2859"
android:pathData="M49.4,28.7c1.7-1.7,3.5-3.3,5.4-4.8c1.9,1.5,3.7,3.1,5.4,4.8c9,9.2,14.4,21.7,14.4,35.3c0,0.7,0,1.5-0.1,2.2 H57.6l10.9-6.8c-0.5-6-2.4-11.8-5.6-17.2c-2.2-3.7-5-7-8.2-9.7c-3.2,2.7-5.9,6-8.2,9.7c-3.2,5.3-5,11.2-5.6,17.2l4.6,6.8H35 c0-0.7-0.1-1.5-0.1-2.2C34.9,50.3,40.4,37.9,49.4,28.7z" />
<path
android:fillColor="#006A4D"
android:pathData="M41.9,28.7c-1.7-1.7-3.5-3.3-5.4-4.8c-1.9,1.5-3.7,3.1-5.4,4.8c-9,9.2-14.4,21.7-14.4,35.3 c0,0.7,0,1.5,0.1,2.2h16.9l-10.9-6.8c0.5-6,2.4-11.8,5.6-17.2c2.2-3.7,5-7,8.2-9.7c3.2,2.7,5.9,6,8.2,9.7c3.2,5.3,5,11.2,5.6,17.2 l-4.6,6.8h10.6c0-0.7,0.1-1.5,0.1-2.2C56.3,50.3,50.8,37.9,41.9,28.7z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M146.4,49.5c-0.2-5.3-0.5-11.7-0.5-17.2h-0.2c-1.3,5-3,10.5-4.9,15.7l-6,17.8h-5.8l-5.5-17.5 c-1.6-5.2-3-10.8-4.1-15.9h-0.1c-0.2,5.4-0.4,11.9-0.7,17.5l-0.9,16.5h-7l2.7-41h9.9l5.4,16.5c1.5,4.8,2.7,9.7,3.8,14.2h0.2 c1.1-4.4,2.5-9.5,4.1-14.3l5.7-16.4h9.7l2.4,41h-7.3L146.4,49.5z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M170,54.6l-3.5,11.6h-7.7l13.1-41h9.6l13.3,41h-8L183,54.6H170z M181.7,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L171.2,49H181.7z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M200.5,25.2h7.5V60h16.9v6.3h-24.3V25.2z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M230.5,25.8c3.3-0.5,7.5-0.9,11.9-0.9c7.7,0,13,1.6,16.7,4.7c4,3.2,6.4,8.1,6.4,15.1c0,7.3-2.5,12.8-6.4,16.3 c-4.1,3.7-10.6,5.6-18.6,5.6c-4.4,0-7.7-0.2-10.1-0.5V25.8z M237.9,60.5c1,0.2,2.6,0.2,4.1,0.2c9.7,0.1,15.5-5.3,15.5-15.7 c0.1-9.1-5.2-14.2-14.5-14.2c-2.4,0-4.1,0.2-5.1,0.4V60.5z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M279.5,25.2v41H272v-41H279.5z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M297.4,66.2l-13.3-41h8.2l5.6,18.6c1.6,5.2,2.9,10,4,15h0.1c1.1-4.9,2.6-9.9,4.2-14.8l6-18.7h8l-14.2,41 H297.4z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M347.6,48.1h-15.5v12h17.3v6.1h-24.8v-41h23.8v6.1h-16.4V42h15.5V48.1z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M355.8,58.1c2.4,1.4,6.1,2.6,9.9,2.6c4.8,0,7.5-2.3,7.5-5.6c0-3.1-2.1-4.9-7.3-6.8c-6.8-2.4-11.1-6-11.1-11.9 c0-6.7,5.6-11.8,14.5-11.8c4.4,0,7.7,1,9.9,2.1l-1.8,6c-1.5-0.8-4.3-1.9-8.2-1.9c-4.7,0-6.8,2.6-6.8,4.9c0,3.2,2.4,4.6,7.8,6.8 c7.1,2.7,10.6,6.3,10.6,12.2c0,6.6-5,12.3-15.6,12.3c-4.3,0-8.8-1.2-11.1-2.6L355.8,58.1z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M407.6,25.2v41h-7.5v-41H407.6z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M416,58.1c2.4,1.4,6.1,2.6,9.9,2.6c4.8,0,7.5-2.3,7.5-5.6c0-3.1-2.1-4.9-7.3-6.8c-6.8-2.4-11.1-6-11.1-11.9 c0-6.7,5.6-11.8,14.5-11.8c4.4,0,7.7,1,9.9,2.1l-1.8,6c-1.5-0.8-4.3-1.9-8.2-1.9c-4.7,0-6.8,2.6-6.8,4.9c0,3.2,2.4,4.6,7.8,6.8 c7.1,2.7,10.6,6.3,10.6,12.2c0,6.6-5,12.3-15.6,12.3c-4.3,0-8.8-1.2-11.1-2.6L416,58.1z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M447.8,25.2h7.5V60h16.9v6.3h-24.3V25.2z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M486.2,54.6l-3.5,11.6H475l13.1-41h9.6l13.3,41h-8l-3.7-11.6H486.2z M498,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L487.4,49H498z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M551.1,49.5c-0.2-5.3-0.5-11.7-0.5-17.2h-0.2c-1.3,5-3,10.5-4.9,15.7l-6,17.8h-5.8l-5.5-17.5 c-1.6-5.2-3-10.8-4.1-15.9h-0.1c-0.2,5.4-0.4,11.9-0.7,17.5l-0.9,16.5h-7l2.7-41h9.9l5.4,16.5c1.5,4.8,2.7,9.7,3.8,14.2h0.2 c1.1-4.4,2.5-9.5,4.1-14.3l5.7-16.4h9.7l2.4,41h-7.3L551.1,49.5z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M574.3,25.2v41h-7.5v-41H574.3z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M612.3,65c-1.8,0.9-5.7,1.8-10.6,1.8c-13,0-20.9-8.2-20.9-20.6c0-13.5,9.4-21.7,21.9-21.7c4.9,0,8.5,1,10,1.8 l-1.6,6c-1.9-0.9-4.6-1.6-8-1.6c-8.3,0-14.4,5.2-14.4,15.1c0,9,5.3,14.8,14.3,14.8c3,0,6.2-0.6,8.2-1.5L612.3,65z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M631.3,25.8c2.4-0.5,6.7-0.9,10.9-0.9c5.5,0,8.9,0.7,11.7,2.6c2.6,1.5,4.3,4.2,4.3,7.7c0,3.8-2.4,7.2-6.8,8.9 v0.1c4.3,1.1,8.3,4.5,8.3,10.2c0,3.7-1.6,6.5-4,8.5c-2.9,2.6-7.7,3.8-15.2,3.8c-4.1,0-7.3-0.3-9.2-0.5V25.8z M638.7,42h3.8 c5.2,0,8.1-2.4,8.1-5.9c0-3.8-2.9-5.6-7.7-5.6c-2.2,0-3.5,0.1-4.3,0.3V42z M638.7,60.8c1,0.1,2.3,0.2,4,0.2c4.8,0,9.1-1.8,9.1-6.9 c0-4.7-4.1-6.7-9.3-6.7h-3.7V60.8z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M674.3,54.6l-3.5,11.6h-7.7l13.1-41h9.6l13.3,41h-8l-3.7-11.6H674.3z M686.1,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L675.5,49H686.1z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M704.8,66.2v-41h8.5l10.6,17.6c2.7,4.6,5.1,9.3,7,13.7h0.1c-0.5-5.5-0.7-10.8-0.7-17V25.2h6.9v41h-7.7 l-10.7-18c-2.6-4.5-5.4-9.6-7.4-14.2l-0.2,0.1c0.3,5.3,0.4,10.7,0.4,17.5v14.7H704.8z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M745.9,25.2h7.4v18.9h0.2c1-1.6,2-3,3-4.4l10.7-14.4h9.2l-14.1,17.5l15,23.5h-8.8L757,47.5l-3.7,4.4v14.4 h-7.4V25.2z" />
</vector>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- MIB receipt header: solid green under the official app's translucent palm texture -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/mib_receipt_amount" />
<item>
<bitmap
android:src="@drawable/mib_receipt_texture"
android:gravity="fill" />
</item>
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.
Binary file not shown.
@@ -48,7 +48,6 @@
android:id="@+id/languageToggle" android:id="@+id/languageToggle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:baselineAligned="false"
app:singleSelection="true" app:singleSelection="true"
app:selectionRequired="true"> app:selectionRequired="true">
@@ -1,63 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
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:gravity="center_horizontal"
android:orientation="vertical"
android:paddingHorizontal="24dp"
android:paddingTop="8dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="Scan this with your authenticator app, or copy the seed. Please keep it private, since anyone who has it can generate your OTP codes."
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<!-- Always black-on-white so scanners read it in dark mode too -->
<com.google.android.material.card.MaterialCardView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:cardBackgroundColor="@android:color/white"
app:cardCornerRadius="16dp"
app:strokeWidth="0dp">
<ImageView
android:id="@+id/ivSeedQr"
android:layout_width="220dp"
android:layout_height="220dp"
android:layout_margin="12dp"
android:contentDescription="OTP seed QR code" />
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/tvSeed"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:fontFamily="monospace"
android:gravity="center"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:textIsSelectable="true" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCopySeed"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Copy seed"
app:icon="@drawable/ic_copy" />
</LinearLayout>
</ScrollView>
@@ -1,98 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
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:paddingHorizontal="24dp"
android:paddingTop="8dp">
<!-- Warning: the old seed is overwritten -->
<com.google.android.material.card.MaterialCardView
style="@style/Widget.Material3.CardView.Filled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="?attr/colorErrorContainer"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp">
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="12dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_info"
app:tint="?attr/colorOnErrorContainer" />
<TextView
android:id="@+id/tvSeedWarning"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnErrorContainer" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilNewSeed"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="New OTP seed"
app:helperText="Base32 secret or otpauth:// link">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etNewSeed"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:imeOptions="actionDone"
android:inputType="textNoSuggestions|textVisiblePassword"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScanNewSeed"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:contentDescription="@string/scan_otp_qr"
android:tooltipText="@string/scan_otp_qr"
app:icon="@drawable/ic_qr_scan" />
</LinearLayout>
<!-- Live preview of the new seed's code, same card as the sign-in screen -->
<include
android:id="@+id/cardOtp"
layout="@layout/view_otp_preview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp" />
</LinearLayout>
</ScrollView>
@@ -1,122 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Full-screen BML receipt, modelled on docs/bmlapi/tmp/recipt_full_*.jpg.
The receipt card is inserted at the top of cardHolder, directly followed by
the Save/Share buttons; the rest of the screen shows the footer colour. -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/bml_receipt_footer">
<!-- Top bar — status bar inset is added as top padding in code -->
<FrameLayout
android:id="@+id/topBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/bml_receipt_bg">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="48dp">
<ImageButton
android:id="@+id/btnBack"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="start|center_vertical"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_chevron_back"
app:tint="@color/bml_receipt_amount"
android:contentDescription="Back" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="Transfer successful"
android:textSize="17sp"
android:textColor="@color/bml_receipt_amount"
android:fontFamily="@font/sofia_pro" />
</FrameLayout>
</FrameLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/bml_receipt_divider" />
<ScrollView
android:id="@+id/scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:overScrollMode="never"
android:scrollbars="none">
<LinearLayout
android:id="@+id/cardHolder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- receipt card goes here (index 0) -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/bml_receipt_bottom_divider" />
<!-- Bottom actions — nav bar inset is added to bottom padding in code -->
<LinearLayout
android:id="@+id/bottomBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/bml_receipt_footer"
android:paddingHorizontal="20dp"
android:paddingTop="20dp"
android:paddingBottom="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSaveFull"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="44dp"
android:insetTop="0dp"
android:insetBottom="0dp"
android:text="Save receipt"
android:textAllCaps="false"
android:letterSpacing="0"
android:textSize="17sp"
android:textColor="#FFFFFF"
android:fontFamily="@font/sofia_pro"
app:backgroundTint="@color/bml_red"
app:cornerRadius="10dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnShareFull"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="44dp"
android:layout_marginTop="8dp"
android:insetTop="0dp"
android:insetBottom="0dp"
android:text="Share receipt"
android:textAllCaps="false"
android:letterSpacing="0"
android:textSize="17sp"
android:textColor="@color/bml_receipt_message"
android:fontFamily="@font/sofia_pro"
app:rippleColor="@color/bml_receipt_divider" />
</LinearLayout>
</LinearLayout>
</ScrollView>
</LinearLayout>
@@ -1,96 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Full-screen MIB receipt. The receipt card is inserted into cardHolder and its green
header is extended under the status bar in code; the close button floats over the
header just below the status bar. Share/Save sit pinned at the bottom of the screen. -->
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/mib_receipt_bg">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ScrollView
android:id="@+id/scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:overScrollMode="never"
android:scrollbars="none">
<FrameLayout
android:id="@+id/cardHolder"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</ScrollView>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/mib_receipt_divider" />
<!-- Bottom actions — nav bar inset is added to bottom padding in code -->
<LinearLayout
android:id="@+id/bottomBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="20dp"
android:paddingTop="20dp"
android:paddingBottom="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnShareFull"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="44dp"
android:insetTop="0dp"
android:insetBottom="0dp"
android:text="Share Receipt"
android:textAllCaps="false"
android:letterSpacing="0"
android:textSize="17sp"
android:textColor="#FFFFFF"
app:backgroundTint="@color/mib_blue"
app:cornerRadius="10dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSaveFull"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="44dp"
android:layout_marginTop="8dp"
android:insetTop="0dp"
android:insetBottom="0dp"
android:text="Save Receipt"
android:textAllCaps="false"
android:letterSpacing="0"
android:textSize="17sp"
android:textColor="@color/mib_blue"
app:backgroundTint="@color/mib_receipt_bg"
app:strokeColor="@color/mib_blue"
app:strokeWidth="1dp"
app:cornerRadius="10dp" />
</LinearLayout>
</LinearLayout>
<!-- Close (back) button — status bar inset is added to top margin in code -->
<ImageButton
android:id="@+id/btnClose"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="top|end"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_mib_receipt_close"
android:contentDescription="Close" />
</FrameLayout>
+330
View File
@@ -0,0 +1,330 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:id="@+id/toAccountBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSecondaryContainer"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@string/chat_to"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvToAccount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvToNickname"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tvToCurrency"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_chat_chip"
android:paddingHorizontal="10dp"
android:paddingVertical="3dp"
android:textAppearance="?attr/textAppearanceLabelLarge"
android:textColor="?attr/colorOnSurface" />
<ImageView
android:id="@+id/ivToSwitch"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="8dp"
android:rotation="90"
android:src="@drawable/ic_arrow_right"
android:importantForAccessibility="no" />
</LinearLayout>
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:id="@+id/saveContactBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurfaceContainerHigh"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/chat_not_in_contacts"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSaveContact"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/chat_save_contact" />
</LinearLayout>
<LinearLayout
android:id="@+id/summaryCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
android:background="@drawable/bg_chat_summary"
android:orientation="horizontal"
android:paddingHorizontal="14dp"
android:paddingVertical="10dp"
android:visibility="gone">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvSumLabel1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvSumValue1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/sumColumn2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvSumLabel2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvSumValue2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/sumColumn3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/tvSumLabel3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvSumValue3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMessages"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp" />
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/tvCannotSend"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:gravity="center"
android:text="@string/chat_cannot_send"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
<LinearLayout
android:id="@+id/composer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="12dp"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/tvCurrencyWarning"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorError"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnFrom"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="start|center_vertical"
android:ellipsize="middle"
android:maxLines="1"
android:textColor="?attr/colorOnSecondaryContainer"
app:icon="@drawable/ic_arrow_right"
app:iconGravity="end"
app:iconSize="16dp"
app:iconTint="?attr/colorOnSecondaryContainer" />
<LinearLayout
android:id="@+id/amountRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilAmount"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1.2"
android:hint="@string/chat_amount"
app:boxCornerRadiusBottomEnd="28dp"
app:boxCornerRadiusBottomStart="28dp"
app:boxCornerRadiusTopEnd="28dp"
app:boxCornerRadiusTopStart="28dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etAmount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="numberDecimal"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilNote"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:hint="@string/chat_note"
app:boxCornerRadiusBottomEnd="28dp"
app:boxCornerRadiusBottomStart="28dp"
app:boxCornerRadiusTopEnd="28dp"
app:boxCornerRadiusTopStart="28dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etNote"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionSend"
android:inputType="textCapSentences"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSend"
style="@style/Widget.Material3.Button.Icon"
android:layout_width="wrap_content"
android:layout_height="56dp"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:text="@string/chat_send"
app:cornerRadius="28dp"
app:icon="@drawable/ic_send"
app:iconGravity="textEnd" />
</LinearLayout>
</LinearLayout>
<!-- Hosts the Transfer page off-screen when only its confirm dialog is wanted. -->
<FrameLayout
android:id="@+id/hiddenTransferHost"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPayAgain"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginHorizontal="12dp"
android:layout_marginVertical="12dp"
android:text="@string/chat_pay_again"
android:visibility="gone"
app:cornerRadius="28dp" />
</LinearLayout>
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
app:startIconDrawable="@android:drawable/ic_menu_search"
app:boxCornerRadiusTopStart="24dp"
app:boxCornerRadiusTopEnd="24dp"
app:boxCornerRadiusBottomStart="24dp"
app:boxCornerRadiusBottomEnd="24dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/chats_search_hint"
android:inputType="text"
android:maxLines="1"
android:imeOptions="actionSearch" />
</com.google.android.material.textfield.TextInputLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvChats"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="4dp"
android:paddingBottom="65dp" />
<TextView
android:id="@+id/emptyView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="32dp"
android:gravity="center"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabScanPay"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_margin="16dp"
android:contentDescription="@string/chat_scan_to_pay"
app:srcCompat="@drawable/ic_qr_scan" />
</FrameLayout>
@@ -130,13 +130,89 @@
android:maxLength="6" /> android:maxLength="6" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<include <com.google.android.material.card.MaterialCardView
android:id="@+id/cardOtp" android:id="@+id/cardOtp"
layout="@layout/view_otp_preview"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:layout_marginBottom="8dp" /> android:layout_marginBottom="8dp"
android:visibility="invisible"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="?attr/colorSecondaryContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Current OTP"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
<TextView
android:id="@+id/tvOtpCode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorOnSecondaryContainer"
android:letterSpacing="0.15"
android:fontFamily="monospace" />
</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
android:id="@+id/otpTimer"
android:layout_width="32dp"
android:layout_height="32dp"
app:indicatorSize="32dp"
app:trackThickness="3dp"
app:indicatorColor="?attr/colorOnSecondaryContainer"
app:trackColor="?attr/colorSecondaryContainer"
android:indeterminate="false" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<TextView <TextView
android:id="@+id/tvError" android:id="@+id/tvError"
@@ -24,7 +24,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp" android:layout_marginHorizontal="24dp"
android:orientation="vertical" android:orientation="vertical"
android:background="@color/bml_receipt_bg"> android:background="#FFFFFF">
<!-- BML icon (bmlicon.jpg, centered, ~52dp ≈ 146/1080*360dp) --> <!-- BML icon (bmlicon.jpg, centered, ~52dp ≈ 146/1080*360dp) -->
<ImageView <ImageView
@@ -32,7 +32,7 @@
android:layout_height="52dp" android:layout_height="52dp"
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:layout_marginTop="20dp" android:layout_marginTop="20dp"
android:src="@drawable/bml_logo_vector" android:src="@drawable/bml_icon"
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:adjustViewBounds="true" android:adjustViewBounds="true"
android:contentDescription="@null" /> android:contentDescription="@null" />
@@ -47,7 +47,7 @@
android:layout_marginBottom="20dp" android:layout_marginBottom="20dp"
android:id="@+id/tvMessage" android:id="@+id/tvMessage"
android:textSize="14sp" android:textSize="14sp"
android:textColor="@color/bml_receipt_message" android:textColor="#2D2D2D"
android:fontFamily="@font/nunito_sans" android:fontFamily="@font/nunito_sans"
android:gravity="center" /> android:gravity="center" />
@@ -75,7 +75,7 @@
android:layout_gravity="center" android:layout_gravity="center"
android:layout_marginBottom="13dp" android:layout_marginBottom="13dp"
android:textSize="42sp" android:textSize="42sp"
android:textColor="@color/bml_receipt_amount" android:textColor="#242424"
android:fontFamily="@font/sofia_pro" android:fontFamily="@font/sofia_pro"
android:gravity="center" /> android:gravity="center" />
@@ -109,66 +109,66 @@
<!-- Status (value = green #8BC155) --> <!-- Status (value = green #8BC155) -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Status" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Status" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" /> <View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvStatus" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="SUCCESS" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" /> <TextView android:id="@+id/tvStatus" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="SUCCESS" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
</LinearLayout> </LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" /> <View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
<!-- Message (value wraps if long) --> <!-- Message (value wraps if long) -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Message" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Message" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" /> <View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvMessageRow" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" /> <TextView android:id="@+id/tvMessageRow" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
</LinearLayout> </LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" /> <View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
<!-- Reference --> <!-- Reference -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" /> <View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvReference" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" /> <TextView android:id="@+id/tvReference" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
</LinearLayout> </LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" /> <View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
<!-- Transaction date --> <!-- Transaction date -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction date" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction date" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" /> <View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" /> <TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
</LinearLayout> </LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" /> <View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
<!-- From (value uppercase) --> <!-- From (value uppercase) -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="From" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="From" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" /> <View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvFrom" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:textAllCaps="false" /> <TextView android:id="@+id/tvFrom" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:textAllCaps="false" />
</LinearLayout> </LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" /> <View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
<!-- To (stacked name + account) --> <!-- To (stacked name + account) -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" /> <View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical" android:gravity="end"> <LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical" android:gravity="end">
<TextView android:id="@+id/tvToName" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" /> <TextView android:id="@+id/tvToName" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" /> <TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" /> <View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
<!-- Amount (value = green #8BC155) --> <!-- Amount (value = green #8BC155) -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Amount" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Amount" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" /> <View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvAmountRow" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" /> <TextView android:id="@+id/tvAmountRow" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
</LinearLayout> </LinearLayout>
<!-- Remarks (hidden when empty) --> <!-- Remarks (hidden when empty) -->
<View android:id="@+id/remarksDivider" android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" android:visibility="gone" /> <View android:id="@+id/remarksDivider" android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" android:visibility="gone" />
<LinearLayout android:id="@+id/remarksRow" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp" android:visibility="gone"> <LinearLayout android:id="@+id/remarksRow" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp" android:visibility="gone">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Remarks" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Remarks" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" /> <View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvRemarks" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" /> <TextView android:id="@+id/tvRemarks" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
</LinearLayout> </LinearLayout>
@@ -199,7 +199,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:gravity="center" android:gravity="center"
android:background="@color/bml_receipt_footer" android:background="#F5F5F5"
android:paddingVertical="22dp"> android:paddingVertical="22dp">
<TextView <TextView
+121 -89
View File
@@ -15,7 +15,7 @@
android:overScrollMode="never" android:overScrollMode="never"
android:scrollbars="none"> android:scrollbars="none">
<!-- Renderable receipt card --> <!-- Renderable receipt card (header grows to fill remaining space) -->
<LinearLayout <LinearLayout
android:id="@+id/receiptCard" android:id="@+id/receiptCard"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -23,35 +23,76 @@
android:layout_marginHorizontal="40dp" android:layout_marginHorizontal="40dp"
android:orientation="vertical"> android:orientation="vertical">
<!-- Green header — from/to avatars and names --> <!-- Green header — fills all space not taken by body -->
<FrameLayout <FrameLayout
android:id="@+id/receiptHeader"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="160dp" android:layout_height="200dp"
android:background="@drawable/mib_receipt_header_bg"> android:background="@drawable/trx_success_bg">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal"
android:gravity="center"
android:paddingHorizontal="24dp">
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical" android:orientation="vertical"
android:gravity="center"> android:gravity="center">
<!-- Plain ImageView: bitmaps are circle-cropped in code (ShapeableImageView's <TextView
hardware-layer mask doesn't render on the scaled card or in captures) --> android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="TRANSACTION RECEIPT"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:letterSpacing="0.08"
android:layout_marginBottom="22dp" />
<ImageView <ImageView
android:layout_width="64dp"
android:layout_height="64dp"
android:src="@drawable/ic_receipt_check"
android:contentDescription="@null" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="SUCCESSFUL"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:letterSpacing="0.05"
android:layout_marginTop="18dp" />
</LinearLayout>
</FrameLayout>
<!-- White body — fixed size content -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="#FFFFFF"
android:paddingTop="16dp">
<!-- Avatars row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:paddingHorizontal="24dp"
android:paddingBottom="14dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivFromAvatar" android:id="@+id/ivFromAvatar"
android:layout_width="52dp" android:layout_width="52dp"
android:layout_height="52dp" android:layout_height="52dp"
android:scaleType="fitCenter" app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
android:contentDescription="@null" />
<TextView <TextView
android:id="@+id/tvFromLabel" android:id="@+id/tvFromLabel"
@@ -59,9 +100,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:textSize="12sp" android:textSize="12sp"
android:maxWidth="110dp" android:textColor="#565656"
android:textStyle="bold"
android:textColor="#FFFFFF"
android:gravity="center" android:gravity="center"
android:maxLines="2" /> android:maxLines="2" />
@@ -70,25 +109,22 @@
<ImageView <ImageView
android:layout_width="24dp" android:layout_width="24dp"
android:layout_height="24dp" android:layout_height="24dp"
android:layout_marginHorizontal="10dp" android:layout_marginHorizontal="12dp"
android:src="@drawable/ic_arrow_right" android:src="@drawable/ic_arrow_right"
android:tint="#FFFFFF"
android:contentDescription="@null" /> android:contentDescription="@null" />
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical" android:orientation="vertical"
android:gravity="center"> android:gravity="center">
<!-- Plain ImageView: bitmaps are circle-cropped in code (ShapeableImageView's <com.google.android.material.imageview.ShapeableImageView
hardware-layer mask doesn't render on the scaled card or in captures) -->
<ImageView
android:id="@+id/ivToAvatar" android:id="@+id/ivToAvatar"
android:layout_width="52dp" android:layout_width="52dp"
android:layout_height="52dp" android:layout_height="52dp"
android:scaleType="fitCenter" app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
android:contentDescription="@null" />
<TextView <TextView
android:id="@+id/tvToLabel" android:id="@+id/tvToLabel"
@@ -96,9 +132,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:textSize="12sp" android:textSize="12sp"
android:maxWidth="110dp" android:textColor="#565656"
android:textStyle="bold"
android:textColor="#FFFFFF"
android:gravity="center" android:gravity="center"
android:maxLines="2" /> android:maxLines="2" />
@@ -106,92 +140,81 @@
</LinearLayout> </LinearLayout>
</FrameLayout> <View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<!-- Body --> <!-- Total Amount -->
<LinearLayout <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:text="TOTAL AMOUNT"
android:background="@color/mib_receipt_bg"> android:textSize="13sp"
android:textColor="#a0a2a1"
android:letterSpacing="0.08"
android:gravity="center"
android:paddingTop="10dp"
android:paddingBottom="4dp" />
<TextView <TextView
android:id="@+id/tvAmount" android:id="@+id/tvAmount"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="24sp" android:textSize="34sp"
android:textStyle="bold" android:textColor="#f14f0f"
android:textColor="@color/mib_receipt_amount"
android:gravity="center" android:gravity="center"
android:paddingTop="14dp" /> android:paddingBottom="10dp" />
<TextView <View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
android:id="@+id/tvStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Success"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/mib_receipt_amount"
android:gravity="center"
android:paddingBottom="6dp" />
<!-- Transaction # --> <!-- Reference # -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction#" android:textSize="15sp" android:textColor="@color/mib_receipt_label" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference #" android:textSize="15sp" android:textColor="#a0a2a1" />
<TextView android:id="@+id/tvReferenceNo" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" /> <View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvReferenceNo" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
</LinearLayout> </LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" /> <View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<!-- From (sender profile name) --> <!-- To Account -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="From" android:textSize="15sp" android:textColor="@color/mib_receipt_label" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To Account" android:textSize="15sp" android:textColor="#a0a2a1" />
<TextView android:id="@+id/tvFromName" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" /> <View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
</LinearLayout> </LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" /> <View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<!-- To Account (recipient name + account number) --> <!-- To Bank -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="To Account" android:textSize="15sp" android:textColor="@color/mib_receipt_label" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To Bank" android:textSize="15sp" android:textColor="#a0a2a1" />
<TextView android:id="@+id/tvToAccount" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" /> <View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvToBank" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
</LinearLayout> </LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" /> <View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<!-- Bank -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Bank" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
<TextView android:id="@+id/tvToBank" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
<!-- Transaction Type -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction Type" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
<TextView android:id="@+id/tvTransactionType" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
<!-- Transaction Date --> <!-- Transaction Date -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction Date" android:textSize="15sp" android:textColor="@color/mib_receipt_label" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction Date" android:textSize="15sp" android:textColor="#a0a2a1" />
<TextView android:id="@+id/tvTransactionDate" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" /> <View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
</LinearLayout> </LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" /> <View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<!-- Processed Date --> <!-- Value Date -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Processed Date" android:textSize="15sp" android:textColor="@color/mib_receipt_label" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Value Date" android:textSize="15sp" android:textColor="#a0a2a1" />
<TextView android:id="@+id/tvValueDate" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" /> <View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvValueDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
</LinearLayout> </LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" /> <View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<!-- Remarks --> <!-- Purpose -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Remarks" android:textSize="15sp" android:textColor="@color/mib_receipt_label" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Purpose" android:textSize="15sp" android:textColor="#a0a2a1" />
<TextView android:id="@+id/tvPurpose" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" /> <View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvPurpose" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
</LinearLayout> </LinearLayout>
<!-- MIB footer --> <!-- MIB footer -->
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -201,10 +224,19 @@
<ImageView <ImageView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="22dp" android:layout_height="28dp"
android:src="@drawable/mib_logo_full" android:src="@drawable/mib_logo"
android:adjustViewBounds="true" android:adjustViewBounds="true"
android:contentDescription="Maldives Islamic Bank" /> android:contentDescription="@null" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="MALDIVES ISLAMIC BANK"
android:textSize="13sp"
android:textColor="#000000"
android:letterSpacing="0.05" />
</LinearLayout> </LinearLayout>
@@ -109,7 +109,7 @@
android:layout_width="32dp" android:layout_width="32dp"
android:layout_height="32dp" android:layout_height="32dp"
android:layout_marginEnd="16dp" android:layout_marginEnd="16dp"
android:src="@drawable/bml_logo_vector" android:src="@drawable/bml_icon"
android:scaleType="fitCenter" android:scaleType="fitCenter"
android:contentDescription="BML" /> android:contentDescription="BML" />
@@ -282,7 +282,6 @@
android:id="@+id/languageToggle" android:id="@+id/languageToggle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:baselineAligned="false"
app:singleSelection="true" app:singleSelection="true"
app:selectionRequired="true"> app:selectionRequired="true">
@@ -304,34 +303,6 @@
</com.google.android.material.button.MaterialButtonToggleGroup> </com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_receipts"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="24dp"
android:layout_marginBottom="12dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_always_fullscreen_receipt"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchFullscreenReceipt"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
+74
View File
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
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"
android:background="?attr/selectableItemBackground"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivAvatar"
android:layout_width="52dp"
android:layout_height="52dp"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<TextView
android:id="@+id/tvName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="8dp"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/ivAvatar"
app:layout_constraintEnd_toStartOf="@id/tvTime"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/tvPreview"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/tvTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBaseline_toBaselineOf="@id/tvName" />
<TextView
android:id="@+id/tvPreview"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/ivAvatar"
app:layout_constraintEnd_toStartOf="@id/ivPin"
app:layout_constraintTop_toBottomOf="@id/tvName"
app:layout_constraintBottom_toBottomOf="parent" />
<ImageView
android:id="@+id/ivPin"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="8dp"
android:contentDescription="@string/chat_pinned"
android:src="@drawable/ic_pin"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/tvPreview"
app:layout_constraintBottom_toBottomOf="@id/tvPreview" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/bubbleRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingVertical="3dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardBubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="150dp"
android:orientation="vertical"
android:paddingStart="14dp"
android:paddingTop="10dp"
android:paddingEnd="14dp"
android:paddingBottom="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tvAmount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="240dp"
android:textAppearance="?attr/textAppearanceTitleLarge"
android:textStyle="bold" />
<Space
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<ImageButton
android:id="@+id/btnReceipt"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="-8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/chat_view_receipt"
android:padding="8dp"
android:scaleType="fitCenter"
android:src="@drawable/ic_receipt"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tvNote"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:maxWidth="240dp"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="6dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tvAccount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="150dp"
android:layout_marginEnd="8dp"
android:alpha="0.8"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceLabelSmall" />
<TextView
android:id="@+id/tvTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.8"
android:textAppearance="?attr/textAppearanceLabelSmall" />
<TextView
android:id="@+id/tvTicks"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:textAppearance="?attr/textAppearanceLabelMedium" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingVertical="10dp">
<TextView
android:id="@+id/tvDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:paddingHorizontal="12dp"
android:paddingVertical="4dp"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
</FrameLayout>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<FrameLayout
android:id="@+id/transferContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingEnd="12dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivHeaderAvatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:orientation="vertical">
<TextView
android:id="@+id/tvHeaderName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvHeaderAccount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout>
@@ -1,84 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Live TOTP preview card, shared by the sign-in screen and the OTP screen's "Update seed" dialog -->
<com.google.android.material.card.MaterialCardView
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"
android:visibility="invisible"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="?attr/colorSecondaryContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Current OTP"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
<TextView
android:id="@+id/tvOtpCode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorOnSecondaryContainer"
android:letterSpacing="0.15"
android:fontFamily="monospace" />
</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
android:id="@+id/otpTimer"
android:layout_width="32dp"
android:layout_height="32dp"
app:indicatorSize="32dp"
app:trackThickness="3dp"
app:indicatorColor="?attr/colorOnSecondaryContainer"
app:trackColor="?attr/colorSecondaryContainer"
android:indeterminate="false" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
+3 -3
View File
@@ -6,9 +6,9 @@
<item android:id="@+id/nav_accounts" <item android:id="@+id/nav_accounts"
android:icon="@drawable/ic_nav_accounts" android:icon="@drawable/ic_nav_accounts"
android:title="@string/nav_accounts" /> android:title="@string/nav_accounts" />
<item android:id="@+id/nav_contacts" <item android:id="@+id/nav_chats"
android:icon="@drawable/ic_contacts" android:icon="@drawable/ic_chat"
android:title="@string/nav_contacts" /> android:title="@string/nav_chats" />
<item android:id="@+id/nav_transfer" <item android:id="@+id/nav_transfer"
android:icon="@drawable/ic_send" android:icon="@drawable/ic_send"
android:title="@string/transfer" /> android:title="@string/transfer" />
+3
View File
@@ -17,6 +17,9 @@
<item android:id="@+id/nav_pay_mv_qr" <item android:id="@+id/nav_pay_mv_qr"
android:icon="@drawable/ic_qr_scan" android:icon="@drawable/ic_qr_scan"
android:title="@string/pay_mv_qr" /> android:title="@string/pay_mv_qr" />
<item android:id="@+id/nav_chats"
android:icon="@drawable/ic_chat"
android:title="@string/nav_chats" />
<item android:id="@+id/nav_contacts" <item android:id="@+id/nav_contacts"
android:icon="@drawable/ic_contacts" android:icon="@drawable/ic_contacts"
android:title="@string/nav_contacts" /> android:title="@string/nav_contacts" />
-19
View File
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- BML receipt — dark mode -->
<color name="bml_receipt_bg">#191A1C</color>
<color name="bml_receipt_message">#DEDEE0</color>
<color name="bml_receipt_amount">#DEDEE0</color>
<color name="bml_receipt_label">#DEDEE0</color>
<color name="bml_receipt_footer">#000000</color>
<color name="bml_receipt_divider">#3D3E40</color>
<color name="bml_receipt_bottom_divider">#1E1F21</color>
<!-- MIB receipt — dark mode -->
<color name="mib_receipt_bg">#1A1A3C</color>
<color name="mib_receipt_label">#FFFFFF</color>
<color name="mib_receipt_value">#FFFFFF</color>
<color name="mib_receipt_divider">#33335C</color>
<color name="mib_receipt_footer_text">#FFFFFF</color>
<color name="mib_receipt_close_ring">#000000</color>
</resources>
-20
View File
@@ -5,24 +5,4 @@
<color name="seed_secondary">#9AD141</color> <color name="seed_secondary">#9AD141</color>
<color name="color_unpaid">#E85D04</color> <color name="color_unpaid">#E85D04</color>
<color name="ic_logo_background">#E8B547</color> <color name="ic_logo_background">#E8B547</color>
<!-- BML receipt (dark variants in values-night/colors.xml) -->
<color name="bml_receipt_bg">#FFFFFF</color>
<color name="bml_receipt_message">#2D2D2D</color>
<color name="bml_receipt_amount">#242424</color>
<color name="bml_receipt_label">#000000</color>
<color name="bml_receipt_footer">#F5F5F5</color>
<color name="bml_receipt_divider">#E9E9E9</color>
<color name="bml_receipt_bottom_divider">#EBEBEB</color>
<color name="bml_red">#E21B23</color>
<!-- MIB receipt (dark variants in values-night/colors.xml) -->
<color name="mib_receipt_bg">#FFFFFF</color>
<color name="mib_receipt_label">#000000</color>
<color name="mib_receipt_value">#000000</color>
<color name="mib_receipt_divider">#CAC4D0</color>
<color name="mib_receipt_footer_text">#000000</color>
<color name="mib_receipt_amount">#1EA833</color>
<color name="mib_receipt_close_ring">#FFFFFF</color>
<color name="mib_blue">#006FFC</color>
</resources> </resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="action_open_contacts" type="id" />
<item name="action_pin_chat" type="id" />
</resources>
+59 -3
View File
@@ -85,6 +85,7 @@
<string name="nav_add_account">Add Login</string> <string name="nav_add_account">Add Login</string>
<string name="nav_accounts">Accounts</string> <string name="nav_accounts">Accounts</string>
<string name="nav_contacts">Contacts</string> <string name="nav_contacts">Contacts</string>
<string name="nav_chats">Chats</string>
<string name="nav_activities">Recent Transfers</string> <string name="nav_activities">Recent Transfers</string>
<string name="nav_transfer_history">Transaction History</string> <string name="nav_transfer_history">Transaction History</string>
<string name="nav_finances">Finances</string> <string name="nav_finances">Finances</string>
@@ -94,6 +95,7 @@
<string name="nav_more">More</string> <string name="nav_more">More</string>
<string name="nav_desc_accounts">View all your bank accounts</string> <string name="nav_desc_accounts">View all your bank accounts</string>
<string name="nav_desc_contacts">Manage your transfer contacts</string> <string name="nav_desc_contacts">Manage your transfer contacts</string>
<string name="nav_desc_chats">Your transfers with each person, as chats</string>
<string name="nav_desc_transfer">Send money to a contact</string> <string name="nav_desc_transfer">Send money to a contact</string>
<string name="nav_desc_pay_mv_qr">Scan or generate a PayMV QR code</string> <string name="nav_desc_pay_mv_qr">Scan or generate a PayMV QR code</string>
<string name="nav_desc_activities">View your recent transfers</string> <string name="nav_desc_activities">View your recent transfers</string>
@@ -127,6 +129,7 @@
<string name="paymvqr_save_failed">Failed to save image</string> <string name="paymvqr_save_failed">Failed to save image</string>
<string name="paymvqr_include_phone">Include phone number</string> <string name="paymvqr_include_phone">Include phone number</string>
<string name="paymvqr_reference_hint">Reference (optional)</string> <string name="paymvqr_reference_hint">Reference (optional)</string>
<string name="paymvqr_reference_default">PayMV QR Transfer</string>
<!-- Toolbar --> <!-- Toolbar -->
<string name="action_lock">Lock app</string> <string name="action_lock">Lock app</string>
@@ -157,8 +160,6 @@
<string name="theme_dark">Dark</string> <string name="theme_dark">Dark</string>
<string name="settings_pitch_black">Pitch Black</string> <string name="settings_pitch_black">Pitch Black</string>
<string name="settings_accent_color">Accent Color</string> <string name="settings_accent_color">Accent Color</string>
<string name="settings_receipts">Receipts</string>
<string name="settings_always_fullscreen_receipt">Always show full screen receipt</string>
<string name="accent_blue">Blue</string> <string name="accent_blue">Blue</string>
<string name="accent_orange">Red</string> <string name="accent_orange">Red</string>
<string name="accent_green">Green</string> <string name="accent_green">Green</string>
@@ -296,7 +297,6 @@
<!-- BML QR Pay --> <!-- BML QR Pay -->
<string name="bml_qr_looking_up">Looking up merchant…</string> <string name="bml_qr_looking_up">Looking up merchant…</string>
<string name="bml_qr_lookup_failed">Could not load merchant details</string> <string name="bml_qr_lookup_failed">Could not load merchant details</string>
<string name="transfer_bml_txn_lookup_failed">Could not load BML payment for this transaction ID</string>
<string name="bml_qr_payment_success">Payment Successful</string> <string name="bml_qr_payment_success">Payment Successful</string>
<string name="bml_qr_select_account">Select a BML account to pay from</string> <string name="bml_qr_select_account">Select a BML account to pay from</string>
@@ -402,4 +402,60 @@
<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> <string name="drag_to_reorder">Drag to reorder</string>
<!-- Chats -->
<string name="chats_search_hint">Search chats</string>
<string name="chats_empty">Chats start from now. New BML transfers will show up here.</string>
<string name="chats_empty_since">No transfers since %s. New BML transfers will show up here.</string>
<string name="chats_no_results">No chats found</string>
<string name="chat_preview_sent">You sent %s</string>
<string name="chat_preview_received">Received %s</string>
<string name="chat_preview_paid">Paid %s</string>
<string name="chat_today">Today</string>
<string name="chat_yesterday">Yesterday</string>
<string name="chat_from_account">From account</string>
<string name="chat_pick_account">Send from</string>
<string name="chat_pick_card">Pay with</string>
<string name="chat_from_chip">%1$s · %2$s · %3$s</string>
<string name="chat_currency_warning">⚠ Sending USD to an MVR account. It will be converted at BML\'s rate and can\'t be reversed.</string>
<string name="chat_currency_blocked">MVR can\'t be sent to a USD account. Pick a USD account to send from.</string>
<string name="chat_cant_send_mvr_to_usd">Can\'t send MVR to a USD account</string>
<string name="chat_needs_usd_source">Needs a USD account to send from</string>
<string name="chat_transfer_sent">Transfer sent</string>
<string name="chat_payment_sent">Payment sent</string>
<string name="chat_view_receipt">Receipt</string>
<string name="chat_not_in_contacts">Not in your contacts</string>
<string name="chat_save_contact">Save contact</string>
<string name="chat_send">Send</string>
<string name="chat_to">To</string>
<string name="chat_pick_recipient_account">Send to</string>
<string name="chat_receipt_missing">Receipt no longer available</string>
<string name="chat_amount">Amount</string>
<string name="chat_amount_invalid">Enter an amount</string>
<string name="chat_cannot_send">Account number not known for this chat. Save this person as a contact, or send once from Transfer, to send from here.</string>
<string name="chat_into_short">into %s</string>
<string name="chat_account_to">%1$s → %2$s</string>
<string name="chat_tick_seen">Seen in BML alert</string>
<string name="chat_tick_booked">Booked in BML history</string>
<string name="chat_note">Note</string>
<string name="chat_pay_again">Pay again with Scan to Pay</string>
<string name="chat_pay_business">Pay %s</string>
<string name="chat_scan_to_pay">Scan to Pay</string>
<string name="chat_merchant_subtitle">Business · Scan to Pay &amp; card</string>
<string name="chat_sum_sent">%s · sent</string>
<string name="chat_sum_received">received</string>
<string name="chat_sum_net">net</string>
<string name="chat_sum_spent">Spent here in %s</string>
<string name="chat_sum_payments">payments</string>
<string name="chat_send_again">Send again · %s</string>
<string name="chat_view_receipt_full">View receipt</string>
<string name="chat_copy_reference">Copy reference</string>
<string name="chat_reference_copied">Reference copied</string>
<string name="chat_pinned">Pinned</string>
<string name="chat_pin">Pin</string>
<string name="chat_unpin">Unpin</string>
<string name="chat_selected_one">1 selected</string>
<string name="chat_pinned_msg">%s pinned</string>
<string name="chat_unpinned_msg">%s unpinned</string>
<string name="chat_undo">Undo</string>
</resources> </resources>
+1 -1
View File
@@ -128,4 +128,4 @@ Fetch all four service groups in sequence. For each group:
--- ---
[← Profile Picture](06-profile-picture.md) &nbsp;&nbsp;&nbsp; **Next →** [PayMV QR](08-paymv-qr.md) [← Profile Picture](06-profile-picture.md)
-40
View File
@@ -1,40 +0,0 @@
# PayMV QR (Receive)
> ⚠️ **Work in progress.** Thijooree does not call this endpoint yet. It generates Fahipay QRs locally, and the Fahipay app currently rejects those as **"Invalid QR"**. See [PayMV QR Format → Fahipay](../thijooree/18-paymv-qr-format.md#fahipay-work-in-progress).
Fahipay's app does **not** build its receive QR on the device. Its `PayMVQR` screen asks the server for a finished card image and displays it. Found by decompiling the app (v2.0.2, Hermes bundle), **not yet confirmed with a traffic capture**: request headers and the exact response shape are unverified.
---
## Endpoint
```
GET api/app/qr/?lang=<lang>&type=p2p&amount=<amount>
```
The app also has a `POST api/app/qr/` variant, sent as form data with `type=p2p`, `lang`, `version`, `platform=app`, `amount` and `device[...]` fields.
## Response
The app reads the image from the first of these fields that is present: `qr_image`, `qr`, `image`, `qr_url`, `qr_code`. The value is either an `http…` URL or raw base64, which the app prefixes with `data:image/png;base64,`. The payload text is read from `qr_code_text` / `qrCode`.
## The card image
A 1240 × 1322 PNG:
- Blue `#005DA3` card with a 6 px border.
- The square Fahipay icon plus the "FahiPay" wordmark top-left, and "PayMV QR" top-right.
- A blue panel holding the holder's name and the QR.
- The reference (`62→05`, e.g. `P2KVTPYL4E`) printed vertically in blue in the white margin right of the panel. The amount is not included in it.
- A "MALDIVES NATIONAL QR" footer.
Thijooree reproduces this layout in `PayMvQrFragment.renderFahipayQrCard()`; see [PayMV QR Screen](../thijooree/11-paymv-qr-screen.md#fahipay--renderfahipayqrcard).
Server-issued payload fields that differ from a plain PayMV QR: `60` = `LD` + 4 digits, `62→05` = `P` + 9 chars, `62→08` = `PAYMENT`, and `54` = `***` when there is no amount. Full samples are in [PayMV QR Format](../thijooree/18-paymv-qr-format.md#real-receive-qrs-reference-samples).
---
&nbsp;
---
[← Saved Favourites](07-contacts.md)
-1
View File
@@ -127,7 +127,6 @@ Client Server
| 5 | [Transaction History](05-history.md) | Paginated activity/transaction history | | 5 | [Transaction History](05-history.md) | Paginated activity/transaction history |
| 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) | | 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) |
| 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service | | 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service |
| 8 | [PayMV QR](08-paymv-qr.md) | Server-generated receive QR (`api/app/qr/`) — work in progress |
--- ---
+1 -24
View File
@@ -21,29 +21,6 @@ Each card shows:
Tapping anywhere on the card also copies the current code. If no logins have a seed, an empty-state message is shown instead. Tapping anywhere on the card also copies the current code. If no logins have a seed, an empty-state message is shown instead.
---
## Seed Actions
Long-pressing a card opens a menu with:
### Export seed
A dialog titled `{bank} · {name}` showing:
- A QR code of a minimal `otpauth://totp/{BANK}?secret=…` link (e.g. `otpauth://totp/BML?secret=…`), always drawn black-on-white so it scans in dark mode. No username or issuer is included, and algorithm, digits and period are left out because SHA1, 6 and 30s are the spec defaults. Export is single-account only; `otpauth-migration://` is supported for import but never produced.
- The Base32 seed in groups of 4 (selectable text)
- A **Copy seed** button. The copy is flagged `EXTRA_IS_SENSITIVE`, so Android 13+ hides the value in the clipboard preview.
### Update seed
Replaces the stored seed for that login, e.g. after re-enrolling the authenticator with the bank.
- A red warning banner explains that the old seed is deleted permanently.
- The new seed can be typed or pasted (raw Base32 or an `otpauth://` link) or scanned with the QR button. Scans that contain several accounts (`otpauth-migration://`) ask which one to use.
- Once the input is a valid seed, a live preview shows its current and next code with a countdown so the user can check it against the bank before saving. The preview is the same card as the sign-in screen (`view_otp_preview.xml`, shared by both), and tapping it copies the code. Input that isn't valid Base32, is shorter than 8 characters (such as a pasted 6-digit code), or matches the current seed disables **Replace**.
- **Replace** asks for confirmation ("Delete old seed?"). Confirming calls `CredentialStore.updateMibOtpSeed()` / `updateBmlOtpSeed()`, which overwrite only the encrypted seed. For MIB it also calls `MibLoginFlow.updateOtpSeed()` so silent re-login uses the new seed.
The username, password and sessions are not touched. Other places that need an OTP (transfers, QR pay, pay with card) read the seed from `CredentialStore` each time, so they pick up the new seed immediately.
### Algorithm ### Algorithm
Standard RFC 6238 TOTP: Standard RFC 6238 TOTP:
@@ -81,7 +58,7 @@ The OTP screen is informational — the user copies the displayed code manually
## Security ## Security
The TOTP seeds are stored encrypted in `CredentialStore`. They are never logged or included in error reports. They leave the app only when the user chooses **Export seed**. The TOTP seeds are stored encrypted in `CredentialStore`. They are never logged or included in error reports.
--- ---
+15 -80
View File
@@ -2,102 +2,37 @@
Generates a receive-payment PayMV / Favara QR code. **Generation only** — the send/scan side of PayMV lives in `TransferFragment` via `newInstanceWithAutoScan()` and the [QR scanner](25-qr-scanner.md). Generates a receive-payment PayMV / Favara QR code. **Generation only** — the send/scan side of PayMV lives in `TransferFragment` via `newInstanceWithAutoScan()` and the [QR scanner](25-qr-scanner.md).
> **Fahipay QRs are a work in progress.** Thijooree's Fahipay card matches Fahipay's design, but the Fahipay app currently rejects the QRs it generates as **"Invalid QR"**. BML QRs scan fine. See [PayMV QR Format → Fahipay](18-paymv-qr-format.md#fahipay-work-in-progress).
--- ---
## Fragment — `PayMvQrFragment` ## Fragment — `PayMvQrFragment`
A single screen (no tabs). Re-renders the QR live (300 ms debounce) as the user edits the form. It can also be opened for a saved contact with `PayMvQrFragment.forContact(accountNumber, name, bank)`. The QR then pays into the contact's account, and the account picker and phone toggle are hidden. A single screen (no tabs). Re-renders the QR live as the user edits the form.
### Fields ### Fields
| Field | Source / behaviour | | Field | Source / behaviour |
|---|---| |---|---|
| Source account dropdown | `viewModel.accounts`, filtered to non-card MVR accounts (MIB, M-Faisa and BML USD currently excluded — flagged as TODO in source). Defaults to `CredentialStore.getDefaultAccountNumber()` when set | | Source account dropdown | `viewModel.accounts`, filtered to non-card MVR accounts (MIB and BML USD currently excluded — both flagged as TODO in source). Defaults to `CredentialStore.getDefaultAccountNumber()` when set |
| Amount (`etAmount`) | Optional. Blank / zero / unparseable → open-amount QR. Commas are stripped | | Amount (`etAmount`) | Optional. Blank → open-amount QR |
| Reference (`etReference`) | Free-text purpose, written to tag 62→08. Blank → tag omitted (BML), or `PAYMENT` (Fahipay) | | Reference (`etReference`) | Free-text purpose; defaults to `paymvqr_reference_default` if blank — written to tag 62→08 |
| Include phone (`switchIncludePhone`) | When on, writes the saved BML / Fahipay mobile to sub-tag 26→05, normalised to `+960XXXXXXX` | | Include phone (`switchIncludePhone`) | When on, writes the saved BML / Fahipay mobile to sub-tag 26→05 (auto-prefixed `+960` if 7-digit local) |
### What goes on the card ### Generation
| Item | Value | `buildQrPayload()` assembles a decimal TLV payload per the [PayMV QR Format](18-paymv-qr-format.md):
|---|---|
| Name | BML / MIB: `accountBriefName`. Fahipay: the holder's full name (`profileName`, falling back to the saved Fahipay profile's `fullName`) — **not** the generic "Fahipay Wallet" brief name. Contacts: the contact's name. Always uppercased |
| QR | The payload below, white modules on the card blue, error correction M, no quiet zone |
| Vertical text | The QR's reference (tag 62→05) — see below |
| Amount | **Not printed** on the card (neither bank does); it only appears inside the QR, and in BML's vertical text |
### Vertical text (reference) 1. Tag 26 container: GUI (`mv.favara.mpqr`), acquirer BIC, account number, optional mobile, `IPAY`
2. Acquirer BIC is derived from the source account's bank: `MALBMVMV` (BML) / `MADVMVMV` (MIB) / `FAHIMVMV` (Fahipay)
3. Tag 62 container: random 9-char reference + the purpose text
4. Tag 80 container: GUI + ISO timestamp
5. Appends `"6304"` and computes CRC-16/CCITT-FALSE over the full string
Both banks print a short code vertically beside the QR, reading bottom-to-top. It is the same string as the payload's reference, **tag 62→05**, so Thijooree calculates the reference first and uses it for both. The rendered card image (bank-styled background plus QR) is shown in-place.
| Bank | Reference / vertical text | Example |
|---|---|---|
| BML | Account number converted to **base-32** (digits `0-9A-V`, uppercase), followed by the **amount exactly as typed** (commas removed, no forced decimals), capped at 25 chars | `7730000188362` → `70V3UKKUA`; with amount `100` → `70V3UKKUA100` |
| Fahipay | `P` + 9 random uppercase alphanumeric chars. **No amount** | `P2KVTPYL4E` |
| Other (MIB contacts) | 9 random uppercase alphanumeric chars | `WHQS0SX5O` |
BML's base-32 is `AccountNumbertoBase32` from the BML app: `BigInt(account)`, repeatedly `% 32` into the alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUV`. If the account number isn't numeric, Thijooree falls back to a random 9-char reference.
---
## Card Rendering
Two renderers, chosen by the target's bank. Both return a `Bitmap` shown in `ivQrCard` (`fitCenter`) and used for Share / Save.
### BML (and MIB) — `renderQrCard()`
A 1:1 copy of BML app v2.1.47's `ReceiveCard` React Native component (decompiled from the Hermes bundle). All values are BML's StyleSheet values in dp, laid out for a 560 dp reference screen width (`SCREEN_WIDTH_DP`) and drawn at 2 px/dp (`PX_PER_DP`), so the card comes out about 1024 px wide.
| Element | Spec |
|---|---|
| Colour | `mmaBlue` `#0E5CA4` everywhere |
| Card | Width `sw − 48`. Blue background, radius 20. The white top section is inset 2 dp (top corners 18), which shows as a thin blue border |
| Header row | 32 dp from top, 40 dp side margins. Left: `bml_logo_paymv` ("BANK OF MALDIVES" wordmark, from BML's assets) contained in `0.38·sw × 0.38·sw·0.1117`. Right: "PayMV QR", Sofia Pro Bold, `#0E5CA4`, sized to BML's `0.2·sw × 0.2·sw·0.1733` image box, shifted down 1 dp. MIB uses `mib_faisanet_logo` in the same box |
| QR panel | 24 dp below the header, 40 dp side margins, radius 16, 24 dp bottom margin |
| Name | Roboto (system default) regular, 14 sp, white; 8 + 12 dp above, 16 dp below |
| QR | `0.5·sw` square; 37 dp padding below |
| Vertical text | Roboto 10 sp, **black at 80 % opacity**, rotated −90°. Centred `railW/1.37 − railW/2` right of the QR's right edge and `(qr + railW/1.5)/2` down from the QR top, where `railW = sw/1.85`. Ellipsized to `railW` |
| Footer | "MALDIVES NATIONAL QR", Sofia Pro Bold (`res/font/sofia_pro_bold.ttf`), `0.046·sw`, letter spacing 1.2 dp, 12 dp vertical padding |
### Fahipay — `renderFahipayQrCard()`
Fahipay's app doesn't draw its card; it shows an image generated by Fahipay's server (`api/app/qr/`). Thijooree copies that image's layout, measured in pixels on its **1240 × 1322** canvas. Text is sized so capital letters match the measured cap heights.
| Element | Spec |
|---|---|
| Colour | `#005DA3` |
| Card | Blue, radius 50. White area inset 6 px (top corners 44) down to y 1174. The blue below it is the footer |
| Header row | Square app icon `fahipay_logo` at (94, 94)–(163, 163), then the "FahiPay" wordmark `fahipay_logo_long` at x 178, y 104, 48 px tall — **both, side by side** |
| "PayMV QR" | Sofia Pro Bold, blue, right-aligned at x 1147, cap height 30 (cap top y 101) |
| QR panel | (166, 218)–(1074, 1126), radius 55 |
| Name | Roboto regular, white, centred at x 619, cap height 30 (cap top y 314). Shrinks to fit the panel minus 80 px |
| QR | 562 px at (338, 417) |
| Vertical text | Montserrat Regular (`res/font/montserrat_regular.ttf`), **blue**, cap height 27, rotated −90°. It sits in the **white margin right of the panel**: text starts at y 1087, baseline at x 1171 |
| Footer | "MALDIVES NATIONAL QR", Sofia Pro Bold, white, cap height 55.5 (cap top y 1220), BML's letter spacing (1.2/25.76 em) |
Fonts follow the BML card (Sofia Pro Bold, Roboto), except the vertical text, which keeps Fahipay's Montserrat.
---
## Generation
`buildQrPayload()` assembles a decimal TLV payload per the [PayMV QR Format](18-paymv-qr-format.md#generating-a-receive-payment-qr):
1. Tag 01: `11` (static). Fahipay QRs with an amount use `12` (dynamic)
2. Tag 26: GUI (`mv.favara.mpqr`), acquirer BIC ×2 — `MALBMVMV` (BML) / `MADVMVMV` (MIB) / `FAHIMVMV` (Fahipay), account number, optional mobile, `IPAY`
3. Tag 54: amount as `%.2f`. If there's no amount: omitted (BML), `***` (Fahipay)
4. Tag 59: name, uppercased, max 25 chars
5. Tag 60: Fahipay only — `LD` + 4 random digits
6. Tag 62: the reference (above) + purpose
7. Tag 80: GUI + timestamp `yyyy-MM-dd'T'HH:mm:ss.00000`
8. Appends `"6304"` and computes CRC-16/CCITT-FALSE over the full string
### Actions ### Actions
- **Share** (`btnShare`) — writes `<name>_paymv_qr.png` to the cache and shares it via `FileProvider` + `ACTION_SEND` - **Share** (`btnShare`) — exports the rendered card via `FileProvider` + `ACTION_SEND`
- **Save** (`btnSave`) — writes `<name>_PayMV_QR.png` to `MediaStore.Images` / `Pictures/` - **Save** (`btnSave`, `PayMvQrFragment.kt:78`) — writes the PNG to `MediaStore.Images` / `Pictures/`
--- ---
+11 -92
View File
@@ -31,10 +31,10 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs and in BML POS QRs | | `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"`). Open-amount QRs: absent (Thijooree BML) or `"***"` (BML's and Fahipay's own 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, uppercase in every real QR seen | | `59` | Merchant / recipient name | Max 25 characters |
| `60` | Merchant city / store code | `LD` + 4 digits (e.g. `LD0442`, `LD0745`). Seen in BML POS QRs and in BML's and Fahipay's own receive QRs; meaning of the digits unknown | | `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 |
@@ -66,8 +66,8 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
| Sub-Tag | Field | Notes | | Sub-Tag | Field | Notes |
|---|---|---| |---|---|---|
| `05` | Reference / bill number | Bank-specific — see [Reference (tag 62→05)](#reference-tag-6205). Also printed vertically on the QR card | | `05` | Reference / bill number | 9 random uppercase alphanumeric characters |
| `08` | Payment purpose | Free-form text entered by the payee. BML's app defaults it to `Quickpay Transfer`, Fahipay to `PAYMENT` | | `08` | Payment purpose | Free-form text entered by the payee |
--- ---
@@ -77,7 +77,6 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
|---|---|---| |---|---|---|
| `00` | Domain | `"mv.favara.mpqr"` | | `00` | Domain | `"mv.favara.mpqr"` |
| `01` | Timestamp | ISO 8601 format: `"yyyy-MM-dd'T'HH:mm:ss.00000"` | | `01` | Timestamp | ISO 8601 format: `"yyyy-MM-dd'T'HH:mm:ss.00000"` |
| `02` | Unknown | `"0005"` — only seen in Fahipay's own QR **with an amount**; not generated by Thijooree |
--- ---
@@ -116,98 +115,18 @@ To create a QR that others can scan to pay you:
10 04 IPAY 10 04 IPAY
52 04 0000 ← MCC 52 04 0000 ← MCC
53 03 462 ← MVR 53 03 462 ← MVR
54 <len> <amount> ← "%.2f". Open amount: omit (BML) / "***" (Fahipay) 54 <len> <amount> ← Omit tag entirely if open-amount
58 02 MV 58 02 MV
59 <len> <NAME UP TO 25 CHARS> ← Uppercased 59 <len> <name up to 25 chars>
60 06 LD<4 random digits> ← Fahipay only
62 <len> 62 <len>
05 <len> <reference> ← Bank-specific, see below 05 09 <9 random alphanum chars> ← Reference
08 <len> <purpose text> ← Omit if blank (BML) / "PAYMENT" (Fahipay) 08 <len> <purpose text>
80 <len> 80 <len>
00 15 mv.favara.mpqr 00 15 mv.favara.mpqr
01 <len> <yyyy-MM-dd'T'HH:mm:ss.00000> ← Timestamp 01 <len> <yyyy-MM-dd'T'HH:mm:ss.00000> ← Timestamp
6304<CRC> 6304<CRC>
``` ```
For Fahipay, tag `01` is `12` (dynamic) when an amount is set.
---
## Reference (tag 62→05)
The reference is also the **vertical text** printed beside the QR on both banks' cards, so it is calculated once and used for both (`PayMvQrFragment.generateQr()`).
### BML — base-32 account number + amount
```
reference = base32(accountNumber) + amountAsTyped
```
- `base32` is BML's `AccountNumbertoBase32`: treat the account number as an integer and convert it to base 32 with the alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUV`, most significant digit first
- `amountAsTyped` is the amount field with commas removed and **no forced decimals** (`100` stays `100`, `100.5` stays `100.5`). Empty for open-amount QRs
- Capped at 25 characters. A non-numeric account number falls back to 9 random characters
| Account | Amount | Reference / vertical text |
|---|---|---|
| `7730000188362` | — | `70V3UKKUA` |
| `7730000188362` | `100` | `70V3UKKUA100` |
Confirmed by decoding a QR from BML's app: `62→05` = `70V3UKKUA`, the same as the vertical text on its card.
### Fahipay — `P` + 9 random characters
Fahipay's server issues references like `P135KOKXJY` and `P2KVTPYL4E`: `P` followed by 9 uppercase alphanumerics. The vertical text shows the reference only — **the amount is not appended** (confirmed on a QR carrying amount `55`). Thijooree generates `"P" + 9 random chars`.
### Others
9 random uppercase alphanumeric characters.
---
## Real Receive QRs (Reference Samples)
Decoded from QR images generated by the official apps (CRC verified).
**BML app** (open amount):
```
00020101021126920014mv.favara.mpqr0108MALBMVMV0208MALBMVMV031377300001883620511+96091980261004IPAY6006LD04425204000053034625403***5802MV5915SHIHAM A.RAHMAN6234050970V3UKKUA0817Quickpay Transfer80470014mv.favara.mpqr01252026-09-26T02:29:53.000006304F8E6
```
Note that BML's app places tag `60` *inside* tag `26` here (after `10 IPAY`, as `6006LD0442`).
**Fahipay app**, open amount:
```
00020101021126810014mv.favara.mpqr0108FAHIMVMV0208FAHIMVMV03125008500611080511+96098074051004IPAY5204000053034625403***5802MV5912MOHAMED RAIF6006LD074562250510P135KOKXJY0807PAYMENT80470014mv.favara.mpqr01252026-09-26T03:44:36.0000063042707
```
**Fahipay app**, amount `55`:
```
00020101021226810014mv.favara.mpqr0108FAHIMVMV0208FAHIMVMV03125003600510030511+96091980261004IPAY5204000053034625402555802MV5919SHIHAM ABDUL RAHMAN6006LD097062250510P2KVTPYL4E0807PAYMENT80550014mv.favara.mpqr01252026-09-26T03:22:08.000000204000563045304
```
---
## Fahipay (Work in Progress)
> ⚠️ **Fahipay QRs generated by Thijooree do not work yet.** The Fahipay app rejects them as **"Invalid QR"**. BML QRs from Thijooree scan fine in the BML app.
Fahipay's app doesn't build its QR locally: it fetches it from `GET api/app/qr/?lang=…&type=p2p&amount=…`, and the server returns the finished card image and payload. Things tried so far, with the Fahipay app still reporting invalid:
| Change | Status |
|---|---|
| Mobile `26→05` normalised from Fahipay's stored `960XXXXXXX` to `+960XXXXXXX` | Done (was a real bug) |
| Name `59` uppercased | Done |
| `54` = `***` for open amount, `01` = `12` with an amount | Done |
| `60` = `LD` + 4 random digits | Done |
| `62→08` defaults to `PAYMENT` | Done |
| `62→05` shaped `P` + 9 chars | Done |
| `80→02` = `0005` (amount QRs only) | Not done |
The CRC is correct (verified against all samples). With no amount, Thijooree's payload now has the same fields in the same order as Fahipay's own. The leading theory is that Fahipay's scanner looks up the `P…` reference on Fahipay's server, which issued it. If so, no locally generated QR can pass, and the fix would be to fetch the payload from `api/app/qr/` and render the card around it.
--- ---
## Parsing a PayMV QR (Incoming Scan) ## Parsing a PayMV QR (Incoming Scan)
@@ -300,11 +219,11 @@ exactly one request is made either way.
## Example Payload ## Example Payload
Static BML QR for account `7730000188362`, holder `"AHMED ALI"`, open amount, purpose `"Rent"`: Static QR for account `7700000000123`, holder `"AHMED ALI"`, open amount, purpose `"Rent"`:
``` ```
000201010211268...520400005303462 000201010211268...520400005303462
5802MV5909AHMED ALI6221050970V3UKKUA0804Rent 5802MV5909AHMED ALI6225050912345ABCDEF0804Rent
80...63044A2B 80...63044A2B
``` ```
+2 -2
View File
@@ -19,7 +19,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
| [08 — Contacts](08-contacts.md) | Contact list, add/edit/delete, categories, contact picker sheet | | [08 — Contacts](08-contacts.md) | Contact list, add/edit/delete, categories, contact picker sheet |
| [09 — Activities](09-activities.md) | Local transfer log, TransferReceiptFragment, share/save receipt | | [09 — Activities](09-activities.md) | Local transfer log, TransferReceiptFragment, share/save receipt |
| [10 — OTP Screen](10-otp-screen.md) | TOTP display, real-time countdown, enrolled bank authenticators | | [10 — OTP Screen](10-otp-screen.md) | TOTP display, real-time countdown, enrolled bank authenticators |
| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR, BML/Fahipay card rendering, vertical reference text (Fahipay QRs WIP) | | [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 (drag to reorder), Appearance, Privacy & Security, Notifications, Storage, About | | [14 — Settings](14-settings.md) | Settings hub: Logins (drag to reorder), Appearance, Privacy & Security, Notifications, Storage, About |
@@ -39,7 +39,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
| Document | Description | | Document | Description |
|---|---| |---|---|
| [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, per-bank references, real samples, Fahipay WIP, parsing reference | | [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, QR generation recipe, parsing reference |
| [19 — Parsers](19-parsers.md) | Account display parser architecture — how raw bank API data is normalised into a unified `AccountListDisplay` model | | [19 — Parsers](19-parsers.md) | Account display parser architecture — how raw bank API data is normalised into a unified `AccountListDisplay` model |
| [20 — Transfer Flows](20-transfer-flows.md) | TransferFragment entry points, recipient lookup, transfer type routing, rejected combinations, BML business OTP flow, BML QR merchant payments | | [20 — Transfer Flows](20-transfer-flows.md) | TransferFragment entry points, recipient lookup, transfer type routing, rejected combinations, BML business OTP flow, BML QR merchant payments |
| [AI Security Audit](AI_SECURITY_CHECK.md) | Full source security audit — credential storage, network layer, manifest, data privacy | | [AI Security Audit](AI_SECURITY_CHECK.md) | Full source security audit — credential storage, network layer, manifest, data privacy |
-3
View File
@@ -1,3 +0,0 @@
# FAQ
## [What is and how do i get my TOTP Seed?](totpseed/README.md)
@@ -1,87 +0,0 @@
# Set up BML
Reset your Bank of Maldives authenticator to get a new OTP seed, then add that seed to Thijooree so it can generate your OTP codes.
> [!NOTE]
> You need the BML app signed in, and a BML debit card with its expiry date and CVC to confirm who you are.
> [!IMPORTANT]
> Want the same codes in another authenticator app too, such as Microsoft Authenticator or Google Authenticator? Add the secret key to that app **after step 5 and before step 7**. After you tap **Verify Code**, BML stops showing the secret key and you would have to reset again. See [Export from Microsoft Authenticator](04-export-microsoft.md) for the steps.
<table>
<tr>
<th width="25%">Step 1</th>
<th width="25%">Step 2</th>
<th width="25%">Step 3</th>
<th width="25%">Step 4</th>
</tr>
<tr>
<td align="center"><img src="screenshots/bml_1.jpg" alt="BML app wallet screen with the profile icon highlighted" width="200"></td>
<td align="center"><img src="screenshots/bml_2.jpg" alt="Profile menu with Authenticator Setup highlighted" width="200"></td>
<td align="center"><img src="screenshots/bml_3.jpg" alt="Channel Settings screen with the Reset Authenticator button highlighted" width="200"></td>
<td align="center"><img src="screenshots/bml_4.jpg" alt="Debit card verification form with the Authorize button highlighted" width="200"></td>
</tr>
<tr>
<td valign="top">
<b>Open your profile</b><br>
In the BML app, tap the <b>profile icon</b> in the top-right corner of the Wallet screen.
</td>
<td valign="top">
<b>Open Authenticator Setup</b><br>
In the menu, under <b>Settings</b>, tap <b>Authenticator Setup</b>.
</td>
<td valign="top">
<b>Reset the authenticator</b><br>
On the <b>Security</b> tab, tap <b>Reset Authenticator</b>. This replaces any authenticator app you used before.
</td>
<td valign="top">
<b>Verify your debit card</b><br>
Pick a debit card, enter its expiry month, expiry year and CVC, then tap <b>Authorize</b>.
</td>
</tr>
<tr>
<th>Step 5</th>
<th>Step 6</th>
<th>Step 7</th>
<th>Done</th>
</tr>
<tr>
<td align="center"><img src="screenshots/bml_5.jpg" alt="QR code screen with the copy button next to the secret key highlighted" width="200"></td>
<td align="center"><img src="screenshots/thijooree_1.jpg" alt="Thijooree sign-in screen with the OTP seed filled in and the current OTP shown" width="200"></td>
<td align="center"><img src="screenshots/bml_6.jpg" alt="BML screen with the OTP entered and the Verify Code button highlighted" width="200"></td>
<td align="center"><img src="screenshots/bml_7.jpg" alt="Authenticator reset successfully message" width="200"></td>
</tr>
<tr>
<td valign="top">
<b>Copy the secret key</b><br>
Below the QR code, tap the <b>copy button</b> next to the secret key. Keep this screen open, you will come back to it.
</td>
<td valign="top">
<b>Add the seed to Thijooree</b><br>
Open Thijooree and paste the key into <b>OTP Seed (TOTP Secret)</b>. Tap the <b>Current OTP</b> box to copy the 6-digit code.<br><br>
<i>Adding the key to another authenticator app? Do it now, before step 7.</i>
</td>
<td valign="top">
<b>Verify the code in BML</b><br>
Go back to the BML app, paste the code into the 6-digit code field and tap <b>Verify Code</b>.
</td>
<td valign="top">
<b>All done</b><br>
BML shows <b>Authenticator reset successfully</b>. Thijooree now generates your BML OTP codes.
</td>
</tr>
</table>
## Log in to Thijooree
Once BML shows **Authenticator reset successfully**, go back to Thijooree:
1. Enter your BML **Username** and **Password**.
2. Check that **OTP Seed (TOTP Secret)** still has the key you pasted in step 6.
3. Tap **Login**.
> [!TIP]
> The OTP changes every 30 seconds. If BML rejects the code, copy the current one from Thijooree again and verify straight away.
> [!WARNING]
> The secret key gives full access to your OTP codes. Don't share it or screenshot it where others can see it.
@@ -1,39 +0,0 @@
# Set up MIB
Maldives Islamic Bank doesn't let you reset your authenticator from the app. The only way to get a new OTP seed is to ask customer care, and then wait. And wait some more.
> [!NOTE]
> You need patience, a working email address and a small amount of faith. Results may vary.
## How to do it
1. **Contact customer care**<br>
Get in touch with MIB customer care and ask them to reset your authenticator and send you a new secret key.
2. **Perform the summoning ritual**<br>
Light a candle, face the direction of the nearest MIB branch and chant *"please reply, please reply"* three times. Offering a cup of tea to the ticket gods is optional but recommended.
3. **Wait for the email**<br>
MIB emails you the new secret key. Eventually. Check your inbox, then check your spam folder, then check your inbox again.
4. **Wait more**<br>
Still nothing? This is normal. Refresh your inbox. Touch grass. Refresh your inbox again.
5. **Perform another ritual**<br>
Repeat step 2, but with two candles this time. If it has been a few working days, a polite follow-up to customer care also works, and is less of a fire hazard.
6. **Add the seed to Thijooree**<br>
Once the email arrives, copy the secret key from it. Open Thijooree, go to the **faisanet** sign-in screen and paste the key into **OTP Seed (TOTP Secret)**. The **Current OTP** appears below it.
## Log in to Thijooree
Once the key is in, in Thijooree:
1. Enter your MIB **Username** and **Password**.
2. Tap **Login**.
> [!TIP]
> Already have your MIB account in Google Authenticator or Bitwarden? Skip the rituals entirely and see [Export from Google Authenticator](03-export-googleauthenticator.md) or [Export from Bitwarden](05-export-bitwarden.md).
> [!WARNING]
> The secret key gives full access to your OTP codes. Don't share it, and delete the email once you have logged in.
@@ -1,107 +0,0 @@
# Export from Google Authenticator
Google Authenticator can export your accounts as a QR code. Take a screenshot of that QR code and load it into Thijooree to get the same OTP seed, without resetting anything with your bank.
> [!NOTE]
> Exporting doesn't change your seed. Google Authenticator keeps working, and it shows the same codes as Thijooree.
> [!IMPORTANT]
> Thijooree holds one seed per login. In step 3, select **only** the bank account you want to log in to on Thijooree.
<table>
<tr>
<th width="25%">Step 1</th>
<th width="25%">Step 2</th>
<th width="25%">Step 3</th>
<th width="25%">Step 4</th>
</tr>
<tr>
<td align="center"><img src="screenshots/google_1.jpg" alt="Google Authenticator home screen with the menu button highlighted" width="200"></td>
<td align="center"><img src="screenshots/google_2.jpg" alt="Google Authenticator menu with Transfer codes highlighted" width="200"></td>
<td align="center"><img src="screenshots/google_3.jpg" alt="Select codes screen with only MIB checked and the Next button highlighted" width="200"></td>
<td align="center"><img src="screenshots/google_4.jpg" alt="Scan this QR code screen showing the export QR code" width="200"></td>
</tr>
<tr>
<td valign="top">
<b>Open the menu</b><br>
In Google Authenticator, tap the <b>menu button</b> (three lines) in the top-left corner.
</td>
<td valign="top">
<b>Open Transfer codes</b><br>
Tap <b>Transfer codes</b>.
</td>
<td valign="top">
<b>Select your bank account</b><br>
Check <b>only</b> the bank account you want to log in to on Thijooree, then tap <b>Next</b>.
</td>
<td valign="top">
<b>Screenshot the QR code</b><br>
Take a <b>screenshot</b> of the QR code. Keep this screen open, you will come back to it.
</td>
</tr>
<tr>
<th>Step 5</th>
<th>Step 6</th>
<th>Step 7</th>
<th>Step 8</th>
</tr>
<tr>
<td align="center"><img src="screenshots/thijooree_2.jpg" alt="Thijooree sign-in screen with the QR button next to the OTP seed field highlighted" width="200"></td>
<td align="center"><img src="screenshots/thijooree_3.jpg" alt="Thijooree QR scanner with the Pick image button highlighted" width="200"></td>
<td align="center"><img src="screenshots/thijooree_4.jpg" alt="Photo picker with the QR code screenshot selected" width="200"></td>
<td align="center"><img src="screenshots/google_5.jpg" alt="Google Authenticator Scan this QR code screen with the Next button highlighted" width="200"></td>
</tr>
<tr>
<td valign="top">
<b>Open the QR scanner</b><br>
Open Thijooree and, on the sign-in screen, tap the <b>QR button</b> next to <b>OTP Seed (TOTP Secret)</b>.
</td>
<td valign="top">
<b>Pick an image</b><br>
The QR code is on the same phone, so there is nothing to scan. Tap <b>Pick image</b>.
</td>
<td valign="top">
<b>Select the screenshot</b><br>
Select the QR code screenshot from step 4. Thijooree fills in the OTP seed for you.
</td>
<td valign="top">
<b>Finish the export</b><br>
Go back to Google Authenticator and tap <b>Next</b> on the QR code screen.
</td>
</tr>
<tr>
<th>Step 9</th>
<th colspan="2">Step 10</th>
<th></th>
</tr>
<tr>
<td align="center"><img src="screenshots/google_6.jpg" alt="Remove your exported codes screen with Keep exported codes and Done highlighted" width="200"></td>
<td align="center"><img src="screenshots/google_7.jpg" alt="Google Authenticator list with the MIB code highlighted" width="200"></td>
<td align="center"><img src="screenshots/thijooree_5.jpg" alt="Thijooree sign-in screen with the Current OTP highlighted, matching Google Authenticator" width="200"></td>
<td></td>
</tr>
<tr>
<td valign="top">
<b>Keep the exported codes</b><br>
Select <b>Keep exported codes</b> and tap <b>Done</b>. Don't remove them, or the account disappears from Google Authenticator.
</td>
<td valign="top" colspan="2">
<b>Check that the codes match</b><br>
Compare the <b>Current OTP</b> in Thijooree with the code for the same account in Google Authenticator. They should be the same, because both use the same seed.
</td>
<td></td>
</tr>
</table>
## Log in to Thijooree
Once the codes match, in Thijooree:
1. Enter your bank **Username** and **Password**.
2. Tap **Login**.
> [!TIP]
> The OTP changes every 30 seconds. If the codes don't match, wait for both to refresh and compare again. If they still differ, go back to step 3 and check you selected the right account.
> [!WARNING]
> The QR code screenshot holds your OTP seed. Delete it from your phone, and from any cloud photo backup, once you have logged in.
@@ -1,62 +0,0 @@
# Export from Microsoft Authenticator
Microsoft Authenticator can't export TOTP seeds unless your phone is rooted, so you can't move an existing seed out of it.
Instead, reset your OTP seed with your bank to get a new secret key. Add that key to Thijooree, and to Microsoft Authenticator too if you want codes in both apps.
> [!IMPORTANT]
> Resetting replaces the old seed. The existing BML entry in Microsoft Authenticator stops working, so you have to add the new key to it again.
## How to do it
1. Follow [Set up BML](01-setup-bml.md) up to **step 5**, where you copy the secret key.
2. Add the key to Microsoft Authenticator using the steps below.
3. Go back to [Set up BML](01-setup-bml.md) and continue from **step 6**.
> [!WARNING]
> Add the key to Microsoft Authenticator **before step 7** (Verify Code). After you verify, BML stops showing the secret key and you would have to reset again.
## Add the key to Microsoft Authenticator
<table>
<tr>
<th width="20%">Step 1</th>
<th width="20%">Step 2</th>
<th width="20%">Step 3</th>
<th width="20%">Step 4</th>
<th width="20%">Done</th>
</tr>
<tr>
<td align="center"><img src="screenshots/msauth_1.jpg" alt="Microsoft Authenticator home screen with the QR code button highlighted" width="160"></td>
<td align="center"><img src="screenshots/msauth_2.jpg" alt="Scan QR Code screen with the Enter code manually button highlighted" width="160"></td>
<td align="center"><img src="screenshots/msauth_3.jpg" alt="Add account screen with Other account highlighted" width="160"></td>
<td align="center"><img src="screenshots/msauth_4.jpg" alt="Add account form with account name and secret key filled in" width="160"></td>
<td align="center"><img src="screenshots/msauth_5.jpg" alt="Bank of Maldives account in the Microsoft Authenticator list showing a 6-digit code" width="160"></td>
</tr>
<tr>
<td valign="top">
<b>Add an account</b><br>
Open Microsoft Authenticator and tap the <b>QR code button</b> in the bottom-right corner.
</td>
<td valign="top">
<b>Enter the code manually</b><br>
BML is on the same phone, so there is nothing to scan. Tap <b>Enter code manually</b>.
</td>
<td valign="top">
<b>Choose the account type</b><br>
Tap <b>Other account (Google, Facebook, etc.)</b>.
</td>
<td valign="top">
<b>Paste the secret key</b><br>
Enter an <b>Account Name</b> such as <i>Bank of Maldives</i>, paste the key from BML into <b>Secret Key</b> and tap <b>Finish</b>.
</td>
<td valign="top">
<b>Account added</b><br>
The account now shows a 6-digit code. It matches the code in Thijooree because both use the same key.
</td>
</tr>
</table>
Now go back to [Set up BML](01-setup-bml.md) and continue from **step 6**. In step 7 you can verify with the code from either Thijooree or Microsoft Authenticator.
For MIB, see [Set up MIB](02-setup-mib.md).
@@ -1,52 +0,0 @@
# Export from Bitwarden
Bitwarden stores the authenticator key of a login in plain text. Copy it from the login's edit screen and paste it into Thijooree to get the same OTP seed, without resetting anything with your bank.
> [!NOTE]
> Copying the key doesn't change your seed. Bitwarden keeps working, and it shows the same codes as Thijooree.
<table>
<tr>
<th width="25%">Step 1</th>
<th width="25%">Step 2</th>
<th width="25%">Step 3</th>
<th width="25%">Step 4</th>
</tr>
<tr>
<td align="center"><img src="screenshots/bitwarden_1.jpg" alt="Bitwarden View login screen for Bank of Maldives with the edit button highlighted" width="200"></td>
<td align="center"><img src="screenshots/bitwarden_2.jpg" alt="Bitwarden Edit login screen with the copy button next to the Authenticator key highlighted" width="200"></td>
<td align="center"><img src="screenshots/thijooree_6.jpg" alt="Thijooree sign-in screen with the OTP seed filled in and the current OTP shown" width="200"></td>
<td align="center"><img src="screenshots/bitwarden_3.jpg" alt="Bitwarden View login screen with the Authenticator key code highlighted, matching Thijooree" width="200"></td>
</tr>
<tr>
<td valign="top">
<b>Edit your bank login</b><br>
In Bitwarden, open the login for your bank and tap the <b>edit button</b> in the bottom-right corner.
</td>
<td valign="top">
<b>Copy the authenticator key</b><br>
Tap the <b>copy button</b> next to <b>Authenticator key</b>.
</td>
<td valign="top">
<b>Add the seed to Thijooree</b><br>
Open Thijooree and paste the key into <b>OTP Seed (TOTP Secret)</b>. The <b>Current OTP</b> appears below it.
</td>
<td valign="top">
<b>Check that the codes match</b><br>
Go back to Bitwarden, close the edit screen without saving and compare the <b>Authenticator key</b> code with the <b>Current OTP</b> in Thijooree. They should be the same, because both use the same seed.
</td>
</tr>
</table>
## Log in to Thijooree
Once the codes match, in Thijooree:
1. Enter your bank **Username** and **Password**.
2. Tap **Login**.
> [!TIP]
> The OTP changes every 30 seconds. If the codes don't match, wait for both to refresh and compare again. If they still differ, copy the key from Bitwarden again and make sure you opened the right login.
> [!WARNING]
> The authenticator key gives full access to your OTP codes. Don't share it or paste it anywhere else.
-27
View File
@@ -1,27 +0,0 @@
# TOTP Seed
## What is a TOTP seed?
A TOTP (Time-based One-Time Password) seed is the secret key your bank gives you when you set up its authenticator. It looks like a string of capital letters and numbers such as `JBSWY3DPEHPK3PXP`, or comes inside a QR code as an `otpauth://` link.
## Thijooree needs the seed to:
- Enable OTP-less Transactions.
- Show your OTP codes (to use official bank app or website along with Thijooree.)
To keep transactions secure, you can have Thijooree ask for your biometrics instead of an OTP. \
Keep your seed private: anyone who has it can make your OTP codes.
## How do I get my TOTP seed?
You get your seed in one of two ways:
### Setup
1. [Set up BML](01-setup-bml.md)
2. [Set up MIB](02-setup-mib.md)
### Export
3. [Export from Google Authenticator](03-export-googleauthenticator.md)
4. [Export from Microsoft Authenticator](04-export-microsoft.md)
5. [Export from Bitwarden](05-export-bitwarden.md)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Some files were not shown because too many files have changed in this diff Show More