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:
@@ -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]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user