Add Tap to Pay feature and misc fixes

Adds BML contactless token fetching, APDU emulation utility, TapToPayViewModel, and TapToPayView with NFC animation. Also updates QR scanner, transfer flow, dashboard, and home views.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 01:27:27 +05:00
co-authored by Claude Sonnet 4.6
parent 160ef97e68
commit 2bbb280380
10 changed files with 1080 additions and 49 deletions
@@ -0,0 +1,171 @@
import Foundation
enum BmlContactlessUtil {
// MARK: - APDU Instruction Bytes
static let INS_SELECT: UInt8 = 0xA4
static let INS_GPO: UInt8 = 0xA8
static let INS_READ: UInt8 = 0xB2
// MARK: - PPSE
static let PPSE_HEX = "325041592E5359532E4444463031"
static let PPSE_BYTES: [UInt8] = [
0x32, 0x50, 0x41, 0x59, 0x2E, 0x53, 0x59, 0x53, 0x2E, 0x44, 0x44, 0x46, 0x30, 0x31
]
// MARK: - Status Words
static let SW_OK_HEX = "9000"
static let SW_OK = Data([0x90, 0x00])
static let SW_UNKNOWN_ERROR = Data([0x6F, 0x00])
static let SW_INS_NOT_SUPPORTED = Data([0x6D, 0x00])
// MARK: - Active token
static var activeToken: BmlWalletToken?
static var onTransactionComplete: ((Bool) -> Void)?
static func setToken(_ token: BmlWalletToken) { activeToken = token }
static func clearToken() { activeToken = nil; onTransactionComplete = nil }
// MARK: - Application label
static func applicationLabel(aidHex: String) -> String {
if aidHex.hasPrefix("A0000000031010") { return "VISA" }
if aidHex.hasPrefix("A0000000041010") { return "MASTERCARD" }
if aidHex.hasPrefix("A000000025") { return "AMEX" }
return "BML"
}
// MARK: - APDU Processing
/// Process an APDU command and return the response bytes.
/// Returns nil if the APDU is malformed.
static func processCommandApdu(_ command: Data) -> Data {
guard command.count >= 5 else { return SW_UNKNOWN_ERROR }
let ins = command[1]
switch ins {
case INS_SELECT:
return handleSelect(command)
case INS_GPO:
return handleGpo()
case INS_READ:
return handleReadRecord()
default:
return SW_INS_NOT_SUPPORTED
}
}
// MARK: - APDU Handlers
private static func handleSelect(_ command: Data) -> Data {
let lc = Int(command[4])
guard command.count >= 5 + lc, lc > 0 else { return SW_UNKNOWN_ERROR }
let data = command.subdata(in: 5..<5 + lc)
if data == Data(PPSE_BYTES) {
guard let token = activeToken else { return SW_UNKNOWN_ERROR }
return hexToData(buildSelectPpseResponse(
aid: token.appCode,
label: applicationLabel(aidHex: token.appCode),
priority: "01"
))
}
guard let token = activeToken else { return SW_UNKNOWN_ERROR }
let aidBytes = Data(hexToBytes(token.appCode))
if data == aidBytes {
return hexToData(buildSelectAidResponse(
aid: token.appCode,
label: applicationLabel(aidHex: token.appCode)
))
}
return SW_UNKNOWN_ERROR
}
private static func handleGpo() -> Data {
let miscData = "008008010100"
let body = tlv("80", miscData)
return hexToData(body + SW_OK_HEX)
}
private static func handleReadRecord() -> Data {
guard let token = activeToken else { return SW_UNKNOWN_ERROR }
let track2 = buildTrack2(token)
let body = tlv("70", tlv("57", track2))
let response = hexToData(body + SW_OK_HEX)
onTransactionComplete?(true)
return response
}
// MARK: - Response Builders
static func buildSelectPpseResponse(aid: String, label: String, priority: String) -> String {
let priorityTlv = tlv("87", priority)
let aidTlv = tlv("4F", aid)
let appEntry = tlv("61", aidTlv + priorityTlv)
let ppseTlv = tlv("84", PPSE_HEX)
let inner = tlv("BF0C", appEntry)
let propTpl = tlv("A5", inner)
let fci = tlv("6F", ppseTlv + propTpl)
return fci + SW_OK_HEX
}
static func buildSelectAidResponse(aid: String, label: String) -> String {
let aidTlv = tlv("84", aid)
let labelTlv = tlv("50", asciiToHex(label))
let pdolTlv = tlv("9F38", "9F6602")
let propTpl = tlv("A5", labelTlv + pdolTlv)
let fci = tlv("6F", aidTlv + propTpl)
return fci + SW_OK_HEX
}
static func buildTrack2(_ token: BmlWalletToken) -> String {
var t2 = "\(token.token)D\(token.expiry)\(token.serviceCode)\(token.data)"
if t2.count % 2 != 0 { t2 += "F" }
return t2
}
// MARK: - TLV Encoding
/// Builds a BER-TLV triplet: tag (hex, 1-2 bytes) + DER length + data (hex).
static func tlv(_ tagHex: String, _ dataHex: String) -> String {
let lenBytes = dataHex.count / 2
let lenHex: String
switch lenBytes {
case 0...0x7F:
lenHex = String(format: "%02X", lenBytes)
case 0x80...0xFF:
lenHex = "81" + String(format: "%02X", lenBytes)
default:
lenHex = "82" + String(format: "%02X", lenBytes >> 8) + String(format: "%02X", lenBytes & 0xFF)
}
return tagHex + lenHex + dataHex
}
// MARK: - Hex Utilities
static func hexToBytes(_ hex: String) -> [UInt8] {
let s = hex.uppercased()
var result: [UInt8] = []
var index = s.startIndex
while index < s.endIndex {
let next = s.index(index, offsetBy: 2)
result.append(UInt8(s[index..<next], radix: 16) ?? 0)
index = next
}
return result
}
static func hexToData(_ hex: String) -> Data {
Data(hexToBytes(hex))
}
static func asciiToHex(_ s: String) -> String {
s.compactMap { $0.asciiValue }.map { String(format: "%02X", $0) }.joined()
}
}
@@ -0,0 +1,85 @@
import Foundation
private let BML_API_BASE = "https://www.bankofmaldives.com.mv/internetbanking"
private let BML_API_UA = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
private let BML_API_VERSION = "2.1.44.348"
final class BmlTapToPayClient {
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 30
return URLSession(configuration: cfg)
}()
func fetchTokens(
session: BmlSession,
cardId: String,
otp: String,
quantity: Int = 3
) async throws -> [BmlWalletToken] {
let url = "\(BML_API_BASE)/api/mobile/walletpayments/gettoken"
// Step 1: initiate
let base: [String: Any] = ["type": "track2", "cardid": cardId, "quantity": quantity]
let step1 = try await post(session: session, url: url, body: base)
if step1.code == 0 { return step1.tokens }
if step1.code != 99 { throw BmlError.serverError(step1.message ?? "Token request failed") }
// Step 2: request OTP channel
var body2 = base
body2["channel"] = "token"
let step2 = try await post(session: session, url: url, body: body2)
if step2.code != 22 { throw BmlError.serverError(step2.message ?? "OTP channel request failed") }
// Step 3: submit TOTP
var body3 = body2
body3["otp"] = otp
let step3 = try await post(session: session, url: url, body: body3)
if step3.code != 0 { throw BmlError.serverError(step3.message ?? "Token fetch failed") }
return step3.tokens
}
private func post(session: BmlSession, url: String, body: [String: Any]) async throws -> TokenResponse {
var req = URLRequest(url: URL(string: url)!)
req.httpMethod = "POST"
req.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
req.setValue(BML_API_UA, forHTTPHeaderField: "User-Agent")
req.setValue(BML_API_VERSION, forHTTPHeaderField: "x-app-version")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await urlSession.data(for: req)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if code == 401 || code == 419 { throw BmlError.sessionExpired }
if code >= 500 { throw BmlError.networkError("Server error HTTP \(code)") }
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw BmlError.invalidResponse
}
let respCode = root["code"] as? Int ?? -1
let message = root["message"] as? String
let payload = root["payload"] as? [[String: Any]] ?? []
let tokens: [BmlWalletToken] = payload.map { item in
BmlWalletToken(
token: item["token"] as? String ?? "",
expiry: item["expiry"] as? String ?? "",
appCode: item["app_code"] as? String ?? "",
serviceCode: item["service_code"] as? String ?? "",
data: item["data"] as? String ?? "",
validUntil: item["valid_until"] as? String ?? ""
)
}
return TokenResponse(code: respCode, message: message, tokens: tokens)
}
private struct TokenResponse {
let code: Int
let message: String?
let tokens: [BmlWalletToken]
}
}