8.2 KiB
Card Verification & Merchant Card Payment
Two linked features:
- Card verification — on the Cards manage screen, a Verify action reads the physical card over NFC (or takes it by hand), checks it matches the on-screen card, and stores the full card details (PAN, expiry, CVV) encrypted on-device.
- Merchant card payment — on Transfer, a BML Merchant Services transaction ID whose merchant has no BML Pay is paid with a verified card via the Pomelo + 3-D Secure flow (BML API → Merchant Card Payment). The same flow pays carrier services by BML card (Dhiraagu Reload and Bill Pay, Ooredoo Raastas and Bill Pay), once the carrier has created the transaction.
⚠️ The merchant card flow is scraped browser/ACS traffic, not a stable API. Storing the CVV is a security/PCI liability. See the API doc's Fragility section.
Card verification
Entry — the Verify button
In manage mode the action row has Change PIN · Freeze · Block · Verify
(fragment_cards.xml, icon ic_card_verify). The button reads Verified once the selected card
has a stored entry (bindManageCardData in PayWithCardFragment.kt).
onVerifyClicked(item) (PayWithCardFragment.kt:296) branches on NFC:
| Device state | Behaviour |
|---|---|
| No NFC hardware | Straight to manual entry (showCardDetailsDialog) |
| NFC off | Dialog: NFC Settings / Manually Verify / Cancel |
| NFC ready | Enter verify mode (tap animation) |
Verify mode
setVerifyMode(enabled, item) (PayWithCardFragment.kt:314) swaps the manage action buttons for
Cancel Verification / Manually Verify, and draws CardVerifyAnimationView
(ui/home/CardVerifyAnimationView.kt) in the empty area — a flat card tapping a phone with NFC
waves, matching the Tap to Pay style, with WAITING / READING / SUCCESS / ERROR
states.
startVerifyReader() (PayWithCardFragment.kt:346) uses NfcAdapter.enableReaderMode (reader,
not HCE). On tap, EmvCardReader.read(tag) (nfc/EmvCardReader.kt:24) runs a minimal contactless
EMV read (PPSE → SELECT AID → GPO → read AFL records) and returns CardData(pan, expiry) from tags
5A / 57 (Track 2) and 5F24. onVerifyCardRead (:367) compares the last 4 digits against
the managed card:
- match → success check mark →
showCardDetailsDialog(item, nfcData)for the CVV; - mismatch / unreadable → error state, then back to waiting.
Card details dialog
showCardDetailsDialog(item, nfcData?) (PayWithCardFragment.kt:406, layout
dialog_card_manual_verify.xml):
- The name is always prefilled read-only from the API-provided holder name (
accountBriefNamefor BML,cardHolderNamefor MIB) — never read off the chip. - After an NFC tap (
nfcData != null): card number + expiry are prefilled and locked; only the CVV is entered. Title showsCard ending <4>. - Manual entry: number + expiry + CVV entered; validated with a Luhn check (
luhnValid,:500), last-4 match, a not-in-the-past expiry (normalizeExpiry,:489), and a 3–4 digit CVV.
saveVerifiedCard (PayWithCardFragment.kt:481) writes the entry and toggles the button to
Verified.
Storage — VerifiedCardStore
util/VerifiedCardStore.kt. Per-card entry keyed by the card's identity (bml:<accountNumber> /
mib:<cardId>), encrypted with the shared CacheEncryption AndroidKeyStore key (same as the other
caches).
VerifiedCard(pan, expiry /*MM/YY*/, cvv, method /*nfc|manual*/, verifiedAt)
save / load / isVerified / keys / remove / clear. Not wiped by the "clear cache" or
"remove login" paths — treated as user data (like profile images).
Merchant card payment
Routing — card-only vs BML Pay
A transaction ID / link typed into Transfer's To field is parsed by
BmlMerchantTxnClient.parseTransactionId and resolved in
TransferFragment.lookupBmlMerchantTransaction (TransferFragment.kt:882):
BmlMerchantTxnClient.fetchPayPage(id)(api/bml/BmlMerchantTxnClient.kt:43) loads/paynow(browser UA — the host is Cloudflare-fronted) and parseswindow.appData.- If
!supportsBmlPay && supportsCard→bmlHandler().payCardMerchant(page)(card flow). - Otherwise → existing QR path (
fetchQrPayload→bmlQrPayTarget→openBmlQr), see Transfer Flows.
On-screen, like the QR merchant mode
BmlTransferHandler.payCardMerchant(page) (ui/home/transfer/BmlTransferHandler.kt:441) renders
into the Transfer screen rather than a one-off dialog, mirroring the BML QR merchant mode:
showCardMerchant(page)(:468) paints the merchant as the To card, fills + locks the amount (these links carry a fixed amount), and disables remarks.- The From picker is limited to BML cards; a verified default card is auto-selected.
- State lives in
TransferDraft.bmlCardMerchant, so it survives tab switches and theme/rotation recreation (repainted viarestoreFromDraft). - The ✕ on the To card and
clearForm()both callclearCardMerchant()(:486), which unlocks and empties the amount and re-enables remarks.
A card is only offered when it is both verified and belongs to a BML login the app has an
OTP seed for (BmlVerifiedCards.payable / isPayable, ui/home/transfer/BmlVerifiedCards.kt) —
the 3-D Secure step needs that seed.
Send
submitCardPayment → confirmCardMerchant shows the shared transfer confirm dialog
(biometric-gated), then executeCardMerchant runs, off the main thread.
CardPayoutTransferHandler calls confirmCardMerchant directly with the page of the
transaction a carrier created:
BmlMerchantCardPayClient().pay(page, card) { Totp.generate(otpSeed) }
where card and otpSeed come from BmlVerifiedCards.load — the VerifiedCardStore entry
(expiry split MM/YY → month/year) and the card's BML login seed. The client (api/bml/BmlMerchantCardPayClient.kt) performs the whole
Pomelo + MPGS + Wibmo 3-D Secure sequence — feeding the BML token TOTP into the ACS OTP form
automatically, retrying once if the first code expired. Once BML confirms, it loads
<id>?wait=1 and follows the redirects to the merchant, which is how the merchant learns it was
paid (Dhiraagu doesn't top up without it). Outcome is shown in the shared processing/success
dialog; failures surface as a toast. If the merchant couldn't be reached, success also toasts
the merchant name and BML transaction id (bml_card_pay_merchant_not_notified).
Key assumption
The 3-D Secure "Authenticator" OTP must be the same soft-token TOTP the app already uses for BML
transfers (CredentialStore.loadBmlCredentials(loginId).otpSeed). This holds for the user's own
BML-issued card on a login the app has. It does not work for a non-BML card, a card belonging to
another login/person, or a card whose 3-D Secure only offers SMS/email OTP.
Files
| File | Role |
|---|---|
ui/home/PayWithCardFragment.kt |
Verify button, verify mode, NFC reader, card details dialog |
ui/home/CardVerifyAnimationView.kt |
"Tap card to verify" animation |
nfc/EmvCardReader.kt |
Minimal contactless EMV read (PAN + expiry) |
util/VerifiedCardStore.kt |
Encrypted per-card store of full details |
res/layout/dialog_card_manual_verify.xml |
Card details form |
api/bml/BmlMerchantTxnClient.kt |
fetchPayPage (merchant-type detection), announceBrowser, QR payload |
api/bml/BmlMerchantCardPayClient.kt |
Pomelo tokenise + 3-D Secure card payment |
ui/home/transfer/BmlTransferHandler.kt |
On-screen card merchant mode + payment |
ui/home/transfer/BmlVerifiedCards.kt |
Which cards can pay by card; loads their details |
ui/home/transfer/CardPayoutTransferHandler.kt |
Carrier services by card: carrier creates the transaction, then confirmCardMerchant |
ui/home/TransferFragment.kt |
Transaction-ID lookup + routing |
Related: Cards · Transfer Flows · API side: Merchant Card Payment