Implement bank transfer app flows

This commit is contained in:
2026-06-08 00:03:57 +05:00
parent ef877217ad
commit 9b281d48a7
127 changed files with 8008 additions and 90 deletions
@@ -0,0 +1,235 @@
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 BmlAccountClient {
private let session: BmlSession
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 30
return URLSession(configuration: cfg)
}()
init(session: BmlSession) {
self.session = session
}
// MARK: - Dashboard / Accounts
func fetchAccounts(
loginTag: String,
profileName: String = "Personal",
profileId: String = ""
) async throws -> [BankAccount] {
let data = try await apiGet("/api/mobile/dashboard")
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == true,
let payload = root["payload"] as? [String: Any],
let dashboard = payload["dashboard"] as? [[String: Any]] else {
return []
}
return parseDashboard(dashboard, loginTag: loginTag, profileName: profileName, profileId: profileId)
}
// MARK: - Profile check (lightweight ping to verify session)
func checkProfile() async throws {
let data = try await apiGet("/api/mobile/profile")
if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == false {
throw BmlError.sessionExpired
}
}
// MARK: - User info
func fetchUserInfo() async throws -> BmlUserInfo? {
let data = try await apiGet("/api/mobile/userinfo")
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == true,
let payload = root["payload"] as? [String: Any],
let user = payload["user"] as? [String: Any] else { return nil }
return BmlUserInfo(
fullName: (user["fullname"] as? String ?? "").trimmingCharacters(in: .whitespaces),
email: (user["email"] as? String ?? "").trimmingCharacters(in: .whitespaces),
mobile: (user["mobile_phone"] as? String ?? "").trimmingCharacters(in: .whitespaces),
customerId: (user["customer_number"] as? String ?? "").trimmingCharacters(in: .whitespaces),
idCard: (user["idcard"] as? String ?? "").trimmingCharacters(in: .whitespaces),
birthdate: (user["birthdate"] as? String ?? "").trimmingCharacters(in: .whitespaces)
)
}
// MARK: - Loan detail
func fetchLoanDetail(internalId: String) async throws -> BmlLoanDetail? {
let data = try await apiGet("/api/mobile/account/\(internalId)")
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == true,
let p = root["payload"] as? [String: Any] else { return nil }
return BmlLoanDetail(
loanAmount: p["loanAmount"] as? Double ?? 0,
outstandingAmt: p["outstandingAmt"] as? Double ?? 0,
repayAmount: p["repayAmount"] as? Double ?? 0,
intRate: p["intRate"] as? Double ?? 0,
loanStatus: p["loanStatus"] as? String ?? "",
startDate: p["startDate"] as? String ?? "",
endDate: p["endDate"] as? String ?? "",
noOfRepayOverdue: p["noOfRepayOverdue"] as? Int ?? 0,
overdueAmount: p["overdueAmount"] as? Double ?? 0
)
}
// MARK: - Transfer OTP channels
func fetchTransferChannels() async throws -> [BmlOtpChannel] {
let data = try await apiGet("/api/mobile/transfer")
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == true,
let arr = (root["payload"] as? [String: Any])?["transfer"] as? [String: Any],
let channels = arr["otpChannel"] as? [[String: Any]] else { return [] }
return channels.map {
BmlOtpChannel(
channel: $0["channel"] as? String ?? "",
description: $0["description"] as? String ?? "",
masked: $0["masked"] as? String ?? ""
)
}
}
// MARK: - Dashboard parsing
private func parseDashboard(
_ dashboard: [[String: Any]],
loginTag: String,
profileName: String,
profileId: String
) -> [BankAccount] {
var casaAccounts: [BankAccount] = []
var cardAccounts: [BankAccount] = []
var loanAccounts: [BankAccount] = []
for item in dashboard {
let currency = item["currency"] as? String ?? "MVR"
let accountType = item["account_type"] as? String ?? "CASA"
let product = item["product"] as? String ?? ""
let accountNum = Self.stringValue(item["account"])
let status = item["account_status"] as? String ?? "Active"
let internalId = Self.stringValue(item["id"])
switch accountType {
case "CASA":
let avail = item["availableBalance"] as? Double ?? 0
casaAccounts.append(BankAccount(
id: "BML_\(accountNum)_\(loginTag)",
bank: "BML",
profileName: profileName,
profileType: "BML",
productCode: item["product_code"] as? String ?? "",
accountNumber: accountNum,
accountBriefName: item["alias"] as? String ?? "",
currencyName: currency,
accountTypeName: product,
availableBalance: avail,
currentBalance: item["ledgerBalance"] as? Double ?? 0,
blockedAmount: item["lockedAmount"] as? Double ?? 0,
mvrBalance: currency == "MVR" ? avail : nil,
statusDesc: status,
profileImageHash: nil,
loginTag: loginTag,
profileId: profileId,
internalId: internalId
))
case "Loan":
let outstanding = abs(item["availableBalance"] as? Double ?? 0)
loanAccounts.append(BankAccount(
id: "BML_LOAN_\(accountNum)_\(loginTag)",
bank: "BML",
profileName: profileName,
profileType: "BML_LOAN",
productCode: item["product_code"] as? String ?? "",
accountNumber: accountNum,
accountBriefName: item["alias"] as? String ?? "",
currencyName: currency,
accountTypeName: product,
availableBalance: outstanding,
currentBalance: outstanding,
blockedAmount: 0,
mvrBalance: nil,
statusDesc: status,
profileImageHash: nil,
loginTag: loginTag,
profileId: profileId,
internalId: internalId
))
case "Card":
let isPrepaid = item["prepaid_card"] as? Bool ?? false
let isVisible = item["account_visible"] as? Bool ?? false
let cardBalance = item["cardBalance"] as? [String: Any]
let avail = cardBalance?["AvailableLimit"] as? Double ?? 0
let current = cardBalance?["CurrentBalance"] as? Double ?? 0
let cardType: String = isPrepaid ? "BML_PREPAID" : isVisible ? "BML_CREDIT" : "BML_DEBIT"
let alias = (item["alias"] as? String ?? "").isEmpty ? product : (item["alias"] as? String ?? product)
cardAccounts.append(BankAccount(
id: "BML_CARD_\(accountNum)_\(loginTag)",
bank: "BML",
profileName: profileName,
profileType: cardType,
productCode: item["product_code"] as? String ?? "",
accountNumber: accountNum,
accountBriefName: alias,
currencyName: currency,
accountTypeName: product,
availableBalance: avail,
currentBalance: current,
blockedAmount: 0,
mvrBalance: currency == "MVR" ? avail : nil,
statusDesc: status,
profileImageHash: nil,
loginTag: loginTag,
profileId: profileId,
internalId: internalId
))
default:
break
}
}
return casaAccounts + cardAccounts + loanAccounts
}
private static func stringValue(_ value: Any?) -> String {
switch value {
case let string as String:
return string
case let int as Int:
return String(int)
case let int64 as Int64:
return String(int64)
case let double as Double:
return double.rounded() == double ? String(Int64(double)) : String(double)
default:
return ""
}
}
// MARK: - HTTP
private func apiGet(_ path: String) async throws -> Data {
var req = URLRequest(url: URL(string: "\(BML_API_BASE)\(path)")!)
req.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
req.setValue(BML_API_UA, forHTTPHeaderField: "User-Agent")
req.setValue(BML_API_VERSION, forHTTPHeaderField: "x-app-version")
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)") }
return data
}
}
@@ -0,0 +1,68 @@
import Foundation
// Mirrors Kotlin BmlContactsClient exactly.
// Endpoint: GET /api/mobile/contacts
// Response: { success: true, payload: [ { id, name, alias, account, status, currency }, ... ] }
final class BmlContactsClient {
private let base = "https://www.bankofmaldives.com.mv/internetbanking"
private let ua = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
private let ver = "2.1.44.348"
private let bmlSession: BmlSession
private let urlSession: URLSession
init(bmlSession: BmlSession) {
self.bmlSession = bmlSession
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 30
self.urlSession = URLSession(configuration: cfg)
}
func fetchContacts(loginTag: String) async throws -> [BankContact] {
var req = URLRequest(url: URL(string: "\(base)/api/mobile/contacts")!)
req.setValue("Bearer \(bmlSession.accessToken)", forHTTPHeaderField: "Authorization")
req.setValue(ua, forHTTPHeaderField: "User-Agent")
req.setValue(ver, forHTTPHeaderField: "x-app-version")
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("HTTP \(code)") }
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == true,
let payload = root["payload"] as? [[String: Any]] else {
return []
}
return payload.compactMap { item -> BankContact? in
let account = item["account"] as? String ?? ""
guard !account.isEmpty else { return nil }
let id = item["id"] as? Int ?? 0
let name = item["name"] as? String ?? ""
let alias = (item["alias"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? name
let status = item["status"] as? String ?? "S"
let currency = item["currency"] as? String ?? "MVR"
return BankContact(
id: "BML_bml_\(id)_\(loginTag)",
benefNo: "bml_\(id)",
benefName: name,
benefNickName: alias == name ? nil : alias,
benefAccount: account,
benefType: "I",
bankColor: "#0066A1",
benefBankName: "Bank of Maldives",
bankCode: "",
benefStatus: status,
transferCyDesc: currency,
customerImgHash: nil,
benefCategoryId: "BML",
profileId: loginTag,
source: "BML"
)
}
}
}
@@ -0,0 +1,64 @@
import Foundation
// Mirrors Kotlin BmlForeignLimitsClient exactly.
// Endpoint: GET https://app.bankofmaldives.com.mv/api/v2/foreign-limits
final class BmlForeignLimitsClient {
private let base = "https://app.bankofmaldives.com.mv/api/v2"
private let ua = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
private let ver = "2.1.44.348"
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 30
return URLSession(configuration: cfg)
}()
func fetchForeignLimits(session: BmlSession) async throws -> [BmlForeignLimit] {
var req = URLRequest(url: URL(string: "\(base)/foreign-limits")!)
req.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
req.setValue(ua, "User-Agent")
req.setValue(ver, "x-app-version")
req.setValue("application/json","Accept")
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("HTTP \(code)") }
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == true,
let payload = root["payload"] as? [[String: Any]] else {
return []
}
return payload.map { item in
let usage = item["usageByCategory"] as? [String: Any] ?? [:]
let atm = usage["ATM"] as? [String: Any] ?? [:]
let ecom = usage["ECOM"] as? [String: Any] ?? [:]
let pos = usage["POS"] as? [String: Any] ?? [:]
return BmlForeignLimit(
type: item["type"] as? String ?? "Debit",
used: item["used"] as? Double ?? 0,
totalLimit: item["totalLimit"] as? Double ?? 0,
generalCap: item["generalCap"] as? Double ?? 0,
generalRemaining: item["generalRemaining"] as? Double ?? 0,
medicalRemaining: item["medicalRemaining"] as? Double ?? 0,
isAtmEnabled: item["isAtmEnabled"] as? Bool ?? false,
isPosEnabled: item["isPosEnabled"] as? Bool ?? false,
atmRemaining: atm["remaining"] as? Double ?? 0,
atmLimit: atm["limit"] as? Double ?? 0,
ecomRemaining: ecom["remaining"] as? Double ?? 0,
ecomLimit: ecom["limit"] as? Double ?? 0,
posRemaining: pos["remaining"] as? Double ?? 0,
posLimit: pos["limit"] as? Double ?? 0
)
}
}
}
private extension URLRequest {
mutating func setValue(_ value: String, _ field: String) {
setValue(value, forHTTPHeaderField: field)
}
}
+504
View File
@@ -0,0 +1,504 @@
import CryptoKit
import Foundation
private let BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
private let BML_CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
private let BML_REDIRECT = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
private let BML_APP_UA = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
private let BML_APP_VERSION = "2.1.44.348"
private let BML_WEB_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
// Prevents URLSession from following redirects and captures Set-Cookie headers from
// redirect responses. The 302 response is only fully accessible here, before completionHandler(nil)
// is called by the time data(for:delegate:) returns, the redirect response headers may be gone.
//
// Also stores lastRedirectLocation so callers can reliably read the Location header even when
// the async data(for:) return value loses it after redirect cancellation.
private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate {
var onRedirectResponse: (@Sendable (HTTPURLResponse) -> Void)?
// Written synchronously inside willPerformHTTPRedirection before completionHandler(nil);
// read by BmlLoginFlow after data(for:) returns sequential by design, no data race.
var lastRedirectLocation = ""
func urlSession(
_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping @Sendable (URLRequest?) -> Void
) {
lastRedirectLocation = response.value(forHTTPHeaderField: "Location") ?? ""
onRedirectResponse?(response)
completionHandler(nil)
}
}
final class BmlLoginFlow {
private let noRedirect = NoRedirectDelegate()
// Ephemeral session with automatic cookie handling.
// noRedirect.onRedirectResponse feeds Set-Cookie headers from every 302 response
// into forceCookies() the only reliable place to see redirect response headers,
// since data(for:delegate:) receives a stripped response after the redirect is cancelled.
// Declared as `let` (not lazy) to avoid @MainActor inference on the accessing methods.
private let webSession: URLSession
init() {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 30
webSession = URLSession(configuration: cfg, delegate: noRedirect, delegateQueue: nil)
noRedirect.onRedirectResponse = { [weak self] redirectResp in
self?.forceCookies(from: redirectResp)
self?.captureRedirectCookies(from: redirectResp)
}
}
// Extra store for cookies captured from redirect responses.
// Keyed by name; injected explicitly into the authorize request header.
private var redirectCookieStore: [String: String] = [:]
private var codeVerifier = ""
private var codeChallenge = ""
private var deviceId = ""
private(set) var lastProfiles: [BmlProfile] = []
// MARK: - Login
func login(username: String, password: String, otpSeed: String) async throws -> [BmlProfile] {
codeVerifier = generateCodeVerifier()
codeChallenge = generateCodeChallenge(codeVerifier)
deviceId = generateDeviceId()
// Step 1: GET /web/login seeds XSRF-TOKEN + blaze_session cookies
try await webGet("\(BML_BASE_URL)/web/login")
guard let xsrf = xsrfToken() else { throw BmlError.loginFailed("Could not fetch login page") }
// Step 2: POST credentials
let credBody = try jsonBody(["username": username, "password": password, "code": ""])
let credResp = try await webPost("\(BML_BASE_URL)/web/login", body: credBody, xsrf: xsrf)
guard credResp.status == 302 else {
throw BmlError.loginFailed("Login failed — check your username/password")
}
// Step 3: GET 2FA page follow any canonical redirect so we land on the actual page,
// which sets a fresh XSRF-TOKEN cookie needed for the OTP POST.
let twoFaGetResp = try await webGet("\(BML_BASE_URL)/web/login/2fa")
if twoFaGetResp.status == 302 {
let twoFaLoc = twoFaGetResp.location.isEmpty ? noRedirect.lastRedirectLocation : twoFaGetResp.location
if !twoFaLoc.isEmpty {
let twoFaUrl = twoFaLoc.hasPrefix("http") ? twoFaLoc : "https://www.bankofmaldives.com.mv\(twoFaLoc)"
try await webGet(twoFaUrl)
}
}
let xsrf2 = xsrfToken() ?? xsrf
// Step 4: POST TOTP try current window, then ±1 windows to handle device clock skew.
// A successful 2FA POST redirects somewhere other than /web/login (e.g. /web/profile).
// A failed OTP redirects back to /web/login or /web/login/2fa.
// Re-fetch XSRF before each attempt in case the server rotates it on a failed try.
var twoFaSucceeded = false
var lastOtp = ""
var lastRedirectLoc = ""
for timeStep in [0, -1, 1] {
// Refresh XSRF before each attempt server may invalidate it after a failed POST.
let freshXsrf = xsrfToken() ?? xsrf2
let otp = Totp.generate(otpSeed, timeStep: timeStep)
lastOtp = otp
let body = try jsonBody(["code": otp, "channel": "authenticator"])
let resp = try await webPost("\(BML_BASE_URL)/web/login/2fa", body: body, xsrf: freshXsrf)
let loc = resp.location.isEmpty ? noRedirect.lastRedirectLocation : resp.location
lastRedirectLoc = loc
if resp.status == 302 && !loc.contains("/web/login") {
twoFaSucceeded = true
break
}
}
guard twoFaSucceeded else {
throw BmlError.otpFailed("OTP verification failed (code tried: \(lastOtp), server redirected to: \(lastRedirectLoc)). If the code matches your authenticator, the XSRF-TOKEN or session cookie may be invalid.")
}
// Step 5: GET /web/profile 302 = single profile, 200 = picker.
// For 302, follow the redirect (e.g. to /web/redirect) so the server-side OAuth
// session state is established before we call /oauth/authorize.
// NOTE: The async data(for:delegate:) API may not return the Location header on the
// response object after redirect cancellation. We fall back to noRedirect.lastRedirectLocation
// which is written synchronously inside willPerformHTTPRedirection before the task completes.
let profileResp = try await webGet("\(BML_BASE_URL)/web/profile")
if profileResp.status == 302 {
let loc = profileResp.location.isEmpty ? noRedirect.lastRedirectLocation : profileResp.location
if !loc.isEmpty {
let redirectUrl = loc.hasPrefix("http") ? loc : "https://www.bankofmaldives.com.mv\(loc)"
try await webGet(redirectUrl)
}
lastProfiles = [BmlProfile(profileId: username, name: "Personal", type: "Profile", profileType: "default", autoActivated: true)]
} else {
lastProfiles = parseProfiles(profileResp.body)
}
return lastProfiles
}
// MARK: - Profile activation
func activateProfile(_ profile: BmlProfile, loginTag: String) async throws -> BmlActivationResult {
if profile.autoActivated {
let (session, accounts) = try await doOAuthAndFetchAccounts(loginTag: loginTag, profileName: profile.name, profileId: profile.profileId)
return .success(session, accounts)
}
var req = makeWebRequest(url: "\(BML_BASE_URL)/web/profile/\(profile.profileId)")
if let xsrf = xsrfToken() { req.setValue(xsrf, forHTTPHeaderField: "X-XSRF-TOKEN") }
let (_, resp) = try await webSession.data(for: req, delegate: noRedirect)
let http = resp as! HTTPURLResponse
forceCookies(from: http)
let loc = http.value(forHTTPHeaderField: "Location") ?? ""
let needsBusinessOtp = http.statusCode == 302 && loc.contains("/web/profile/2fa/business")
if needsBusinessOtp {
let channels = try await fetchBusinessOtpChannels()
return .needsBusinessOtp(channels)
} else if http.statusCode == 409 || http.statusCode == 302 {
// Follow the redirect to establish server-side OAuth session state
if !loc.isEmpty {
let redirectUrl = loc.hasPrefix("http") ? loc : "https://www.bankofmaldives.com.mv\(loc)"
try await webGet(redirectUrl)
}
let (session, accounts) = try await doOAuthAndFetchAccounts(loginTag: loginTag, profileName: profile.name, profileId: profile.profileId)
return .success(session, accounts)
} else {
throw BmlError.profileActivationFailed("HTTP \(http.statusCode)")
}
}
func requestBusinessOtp(channel: String) async throws {
guard let xsrf = xsrfToken() else { throw BmlError.sessionExpired }
let body = try jsonBody(["code": "", "channel": channel])
let resp = try await webPost("\(BML_BASE_URL)/web/profile/2fa/business", body: body, xsrf: xsrf)
guard resp.status == 302 else { throw BmlError.loginFailed("Failed to request OTP (HTTP \(resp.status))") }
}
func submitBusinessOtp(channel: String, code: String, profile: BmlProfile, loginTag: String) async throws -> (BmlSession, [BankAccount]) {
try await webGet("\(BML_BASE_URL)/web/profile/2fa/business")
guard let xsrf = xsrfToken() else { throw BmlError.sessionExpired }
let body = try jsonBody(["code": code, "channel": channel])
let resp = try await webPost("\(BML_BASE_URL)/web/profile/2fa/business", body: body, xsrf: xsrf)
let loc = resp.location
if resp.status == 409 || (resp.status == 302 && loc.contains("/web/redirect")) {
return try await doOAuthAndFetchAccounts(loginTag: loginTag, profileName: profile.name, profileId: profile.profileId)
} else if resp.status == 302 {
throw BmlError.otpFailed("Invalid OTP — please try again")
} else {
throw BmlError.otpFailed("Business OTP failed (HTTP \(resp.status))")
}
}
// MARK: - Token refresh
func refreshSession(_ session: BmlSession) async throws -> BmlSession {
var req = URLRequest(url: URL(string: "\(BML_BASE_URL)/oauth/token")!)
req.httpMethod = "POST"
req.setValue(BML_WEB_UA, forHTTPHeaderField: "User-Agent")
let fields = [
("grant_type", "refresh_token"),
("refresh_token", session.refreshToken),
("client_id", BML_CLIENT_ID),
("Device-ID", session.deviceId),
("User-Agent", BML_APP_UA),
("x-app-version", BML_APP_VERSION)
]
req.httpBody = Data(fields.map { "\($0.0)=\($0.1)" }.joined(separator: "&").utf8)
req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let (data, _) = try await makeApiSession().data(for: req)
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let newAccess = (obj["access_token"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else {
throw BmlError.sessionExpired
}
let newRefresh = (obj["refresh_token"] as? String ?? "").isEmpty ? session.refreshToken : obj["refresh_token"] as! String
let expiresIn = obj["expires_in"] as? Int64 ?? 0
let expiresAt = expiresIn > 0 ? Int64(Date().timeIntervalSince1970 * 1000) + expiresIn * 1000 : 0
return BmlSession(accessToken: newAccess, deviceId: session.deviceId, refreshToken: newRefresh, expiresAt: expiresAt)
}
// MARK: - OAuth + account fetch
private func doOAuthAndFetchAccounts(
loginTag: String, profileName: String, profileId: String
) async throws -> (BmlSession, [BankAccount]) {
// Device-ID, User-Agent (APP UA), x-app-version are query params same as Android.
var comps = URLComponents(string: "\(BML_BASE_URL)/oauth/authorize")!
comps.queryItems = [
.init(name: "redirect_uri", value: BML_REDIRECT),
.init(name: "client_id", value: BML_CLIENT_ID),
.init(name: "response_type", value: "code"),
.init(name: "state", value: randomUrlSafe(16)),
.init(name: "nonce", value: randomUrlSafe(12)),
.init(name: "code_challenge", value: codeChallenge),
.init(name: "code_challenge_method", value: "S256"),
.init(name: "Device-ID", value: deviceId),
.init(name: "User-Agent", value: BML_APP_UA),
.init(name: "x-app-version", value: BML_APP_VERSION),
]
// Sync any cookies captured from redirect responses into the session cookie storage.
// This covers blaze_identity and any other cookies that forceCookies() may have missed
// due to redirect responses being cancelled before URLSession auto-processes them.
if let storage = webSession.configuration.httpCookieStorage {
for (name, value) in redirectCookieStore where !name.hasPrefix("__REDIRECT__") {
if let cookie = HTTPCookie(properties: [
.name: name,
.value: value,
.domain: "www.bankofmaldives.com.mv",
.path: "/",
.secure: "TRUE"
]) {
storage.setCookie(cookie)
}
}
}
// Build authorize request with all session cookies (now including redirect-captured ones).
var authorizeReq = makeWebRequest(url: comps.url!.absoluteString)
let allCookies = webSession.configuration.httpCookieStorage?.cookies ?? []
let cookieDict: [String: String] = Dictionary(
uniqueKeysWithValues: allCookies.map { ($0.name, $0.value) }
)
if !cookieDict.isEmpty {
authorizeReq.setValue(
cookieDict.map { "\($0.key)=\($0.value)" }.joined(separator: "; "),
forHTTPHeaderField: "Cookie"
)
authorizeReq.httpShouldHandleCookies = false
}
let sentCookieNames = cookieDict.keys.joined(separator: ",")
let redirectMarkers = redirectCookieStore.keys.filter { $0.hasPrefix("__REDIRECT__") }.sorted()
.compactMap { redirectCookieStore[$0] }.joined(separator: "")
let (_, authorizeRaw) = try await webSession.data(for: authorizeReq, delegate: noRedirect)
let authorizeHttp = authorizeRaw as! HTTPURLResponse
forceCookies(from: authorizeHttp)
let loc = authorizeHttp.value(forHTTPHeaderField: "Location") ?? ""
guard !loc.isEmpty,
let urlComps = URLComponents(string: loc),
let code = urlComps.queryItems?.first(where: { $0.name == "code" })?.value else {
let rStore = redirectCookieStore.keys.filter { !$0.hasPrefix("__REDIRECT__") }.sorted().joined(separator: ",")
throw BmlError.oauthFailed("OAuth authorize failed (HTTP \(authorizeHttp.statusCode), location: \(loc), cookies: [\(sentCookieNames)], redirectPaths: [\(redirectMarkers)], redirectStore: [\(rStore)])")
}
let tokenFields = [
("Device-ID", deviceId),
("code", code),
("grant_type", "authorization_code"),
("User-Agent", BML_APP_UA),
("redirect_uri", BML_REDIRECT),
("code_verifier", codeVerifier),
("client_id", BML_CLIENT_ID),
("x-app-version", BML_APP_VERSION)
]
var tokenReq = URLRequest(url: URL(string: "\(BML_BASE_URL)/oauth/token")!)
tokenReq.httpMethod = "POST"
tokenReq.setValue(BML_WEB_UA, forHTTPHeaderField: "User-Agent")
tokenReq.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
tokenReq.httpBody = Data(tokenFields.map { "\($0.0)=\($0.1)" }.joined(separator: "&").utf8)
let (tokenData, _) = try await webSession.data(for: tokenReq, delegate: noRedirect)
guard let tokenObj = try? JSONSerialization.jsonObject(with: tokenData) as? [String: Any],
let accessToken = (tokenObj["access_token"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else {
throw BmlError.oauthFailed("Token exchange failed")
}
let refreshToken = tokenObj["refresh_token"] as? String ?? ""
let expiresIn = tokenObj["expires_in"] as? Int64 ?? 0
let expiresAt = expiresIn > 0 ? Int64(Date().timeIntervalSince1970 * 1000) + expiresIn * 1000 : 0
let session = BmlSession(accessToken: accessToken, deviceId: deviceId, refreshToken: refreshToken, expiresAt: expiresAt)
let client = BmlAccountClient(session: session)
let accounts = try await client.fetchAccounts(loginTag: loginTag, profileName: profileName, profileId: profileId)
return (session, accounts)
}
// MARK: - Business OTP channels
private func fetchBusinessOtpChannels() async throws -> [BmlOtpChannel] {
let resp = try await webGet("\(BML_BASE_URL)/web/profile/2fa/business")
return parseBusinessOtpChannels(resp.body)
}
// MARK: - Inertia.js parsing
private func extractInertiaJson(_ html: String) -> [String: Any]? {
guard let range = html.range(of: #"data-page="([^"]+)""#, options: .regularExpression) else { return nil }
var escaped = String(html[range])
escaped = escaped.replacingOccurrences(of: #"data-page=""#, with: "").dropLast().description
let unescaped = escaped
.replacingOccurrences(of: """, with: "\"")
.replacingOccurrences(of: "&", with: "&")
.replacingOccurrences(of: "'", with: "'")
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
return try? JSONSerialization.jsonObject(with: Data(unescaped.utf8)) as? [String: Any]
}
private func parseProfiles(_ html: String) -> [BmlProfile] {
guard let root = extractInertiaJson(html),
let props = root["props"] as? [String: Any],
let profiles = props["profiles"] as? [[String: Any]] else { return [] }
return profiles.compactMap { p in
guard let profileObj = p["profile"] as? [String: Any] else { return nil }
return BmlProfile(
profileId: p["profile_id"] as? String ?? "",
name: p["name"] as? String ?? "",
type: p["type"] as? String ?? "",
profileType: profileObj["profile_type"] as? String ?? "default",
autoActivated: false
)
}
}
private func parseBusinessOtpChannels(_ html: String) -> [BmlOtpChannel] {
guard let root = extractInertiaJson(html),
let props = root["props"] as? [String: Any],
let channels = props["channels"] as? [[String: Any]] else { return [] }
return channels.map { c in
BmlOtpChannel(
channel: c["channel"] as? String ?? "",
description: c["description"] as? String ?? "",
masked: c["masked"] as? String ?? ""
)
}
}
// MARK: - Cookie helpers
// Stores raw name=value pairs from redirect response cookies into redirectCookieStore.
// Uses direct string parsing of allHeaderFields to bypass HTTPCookie parsing quirks.
// Also records a __REDIRECT__{n} marker so we know if this was ever called at all.
private func captureRedirectCookies(from response: HTTPURLResponse) {
let path = response.url?.path ?? "nil"
let location = response.value(forHTTPHeaderField: "Location") ?? "?"
let markerKey = "__REDIRECT__\(redirectCookieStore.keys.filter { $0.hasPrefix("__REDIRECT__") }.count)"
redirectCookieStore[markerKey] = "\(path)\(location)" // source path redirect destination
for (key, value) in response.allHeaderFields {
guard let k = key.base as? String,
k.lowercased() == "set-cookie",
let v = value as? String else { continue }
// URLSession may combine multiple Set-Cookie headers with "\n"
for cookieLine in v.components(separatedBy: "\n") {
guard let nameValuePart = cookieLine.split(separator: ";").first else { continue }
let parts = nameValuePart.split(separator: "=", maxSplits: 1)
guard parts.count == 2 else { continue }
let name = parts[0].trimmingCharacters(in: .whitespaces)
let val = String(parts[1]).trimmingCharacters(in: .whitespaces)
redirectCookieStore[name] = val
}
}
}
// After every response, force-write Set-Cookie headers into the session's ephemeral
// cookie storage. URLSession may skip automatic Set-Cookie processing for redirect
// responses when the redirect is cancelled via NoRedirectDelegate.completionHandler(nil).
//
// IMPORTANT: allHeaderFields is a [AnyHashable: Any] dict if the server sends multiple
// Set-Cookie headers, URLSession joins them with "\n" into a single entry. Building a
// plain [String: String] and passing it to HTTPCookie.cookies() would only parse the
// first cookie. We split on "\n" and call HTTPCookie.cookies() once per line instead.
private func forceCookies(from response: HTTPURLResponse) {
guard let storage = webSession.configuration.httpCookieStorage,
let url = response.url else { return }
for (key, value) in response.allHeaderFields {
guard let k = key.base as? String,
k.caseInsensitiveCompare("set-cookie") == .orderedSame,
let v = value as? String else { continue }
for line in v.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { continue }
let cookies = HTTPCookie.cookies(
withResponseHeaderFields: ["Set-Cookie": trimmed],
for: url
)
cookies.forEach { storage.setCookie($0) }
}
}
}
private func xsrfToken() -> String? {
guard let url = URL(string: BML_BASE_URL) else { return nil }
return webSession.configuration.httpCookieStorage?
.cookies(for: url)?.first { $0.name == "XSRF-TOKEN" }?.value
}
// MARK: - HTTP helpers
@discardableResult
private func webGet(_ urlString: String) async throws -> (status: Int, body: String, location: String) {
let req = makeWebRequest(url: urlString)
let (data, resp) = try await webSession.data(for: req, delegate: noRedirect)
let http = resp as! HTTPURLResponse
forceCookies(from: http)
return (http.statusCode, String(data: data, encoding: .utf8) ?? "", http.value(forHTTPHeaderField: "Location") ?? "")
}
private func webPost(_ urlString: String, body: Data, xsrf: String) async throws -> (status: Int, body: String, location: String) {
var req = makeWebRequest(url: urlString)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue(xsrf, forHTTPHeaderField: "X-XSRF-TOKEN")
req.httpBody = body
let (data, resp) = try await webSession.data(for: req, delegate: noRedirect)
let http = resp as! HTTPURLResponse
forceCookies(from: http)
return (http.statusCode, String(data: data, encoding: .utf8) ?? "", http.value(forHTTPHeaderField: "Location") ?? "")
}
private func makeWebRequest(url: String) -> URLRequest {
var req = URLRequest(url: URL(string: url)!)
req.setValue(BML_WEB_UA, forHTTPHeaderField: "User-Agent")
return req
}
private func jsonBody(_ dict: [String: String]) throws -> Data {
try JSONSerialization.data(withJSONObject: dict)
}
private func makeApiSession() -> URLSession {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 30
return URLSession(configuration: cfg)
}
// MARK: - PKCE / crypto
private func generateCodeVerifier() -> String {
var bytes = [UInt8](repeating: 0, count: 72)
_ = SecRandomCopyBytes(kSecRandomDefault, 72, &bytes)
return Data(bytes).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private func generateCodeChallenge(_ verifier: String) -> String {
let hash = SHA256.hash(data: Data(verifier.utf8))
return Data(hash).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private func randomUrlSafe(_ byteCount: Int) -> String {
var bytes = [UInt8](repeating: 0, count: byteCount)
_ = SecRandomCopyBytes(kSecRandomDefault, byteCount, &bytes)
return Data(bytes).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private func generateDeviceId() -> String {
var bytes = [UInt8](repeating: 0, count: 8)
_ = SecRandomCopyBytes(kSecRandomDefault, 8, &bytes)
return bytes.map { String(format: "%02x", $0) }.joined()
}
}
+106
View File
@@ -0,0 +1,106 @@
import Foundation
struct BmlSession: Codable {
let accessToken: String
let deviceId: String
let refreshToken: String
let expiresAt: Int64 // Unix millis; 0 = unknown
var isExpired: Bool {
expiresAt > 0 && Int64(Date().timeIntervalSince1970 * 1000) >= expiresAt
}
}
struct BmlProfile: Codable, Identifiable {
let profileId: String
let name: String
let type: String // "Profile" or "Business"
let profileType: String // "default" or "business"
let autoActivated: Bool
var id: String { profileId }
}
struct BmlOtpChannel: Codable {
let channel: String
let description: String
let masked: String
}
enum BmlActivationResult {
case success(BmlSession, [BankAccount])
case needsBusinessOtp([BmlOtpChannel])
}
struct BmlUserInfo {
let fullName: String
let email: String
let mobile: String
let customerId: String
let idCard: String
let birthdate: String
}
struct BmlLoanDetail {
let loanAmount: Double
let outstandingAmt: Double
let repayAmount: Double
let intRate: Double
let loanStatus: String
let startDate: String
let endDate: String
let noOfRepayOverdue: Int
let overdueAmount: Double
}
struct BmlWalletToken {
let token: String
let expiry: String
let appCode: String
let serviceCode: String
let data: String
let validUntil: String
}
struct BmlForeignLimit: Codable {
let type: String
let used: Double
let totalLimit: Double
let generalCap: Double
let generalRemaining: Double
let medicalRemaining: Double
let isAtmEnabled: Bool
let isPosEnabled: Bool
let atmRemaining: Double
let atmLimit: Double
let ecomRemaining: Double
let ecomLimit: Double
let posRemaining: Double
let posLimit: Double
}
enum BmlError: LocalizedError {
case networkError(String)
case loginFailed(String)
case otpFailed(String)
case serverError(String)
case sessionExpired
case profileActivationFailed(String)
case oauthFailed(String)
case invalidResponse
case endpointNotFound
var errorDescription: String? {
switch self {
case .networkError(let msg): return "Network error: \(msg)"
case .loginFailed(let msg): return "Login failed: \(msg)"
case .otpFailed(let msg): return "OTP failed: \(msg)"
case .serverError(let msg): return msg
case .sessionExpired: return "Session expired."
case .profileActivationFailed(let msg): return "Profile activation failed: \(msg)"
case .oauthFailed(let msg): return "OAuth failed: \(msg)"
case .invalidResponse: return "Unexpected server response."
case .endpointNotFound: return "Endpoint not found."
}
}
}
@@ -0,0 +1,238 @@
import Foundation
struct BmlAccountLookupResult: Sendable {
let accountNumber: String
let accountName: String
let bankName: String
let trnType: String // "IAT" BML-to-BML, "QTR" Favara, "DOT" other bank
let bank: String?
}
struct BmlTransferInit: Sendable {
let code: Int // 22 = OTP required
}
struct BmlTransferReceipt: Sendable {
let reference: String
let timestamp: String
let message: String
}
// BML 2-step transfer:
// Step 1 POST /api/mobile/transfer with transfer details (no otp).
// Server responds {"success":true,"code":22} when OTP is required.
// Step 2 POST same endpoint adding otp field.
// Server responds {"success":true,"payload":{"reference":...,"timestamp":...}}.
//
// Field names from Kotlin: debitAccount, creditAccount, debitAmount, transfertype, currency, channel, otp, remarks
final class BmlTransferClient {
private let bmlSession: BmlSession
private let urlSession: URLSession
private let base = "https://www.bankofmaldives.com.mv/internetbanking"
private let ua = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
private let ver = "2.1.44.348"
init(bmlSession: BmlSession) {
self.bmlSession = bmlSession
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 30
self.urlSession = URLSession(configuration: cfg)
}
// MARK: - Account lookup
func lookupAccount(_ input: String) async throws -> BmlAccountLookupResult {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
if Self.isMibAccount(trimmed) {
return try await lookupMibAccount(trimmed)
}
// Kotlin uses /api/mobile/validate/account/<input> for BML/Favara validation.
let data = try await apiGet("/api/mobile/validate/account/\(Self.pathComponent(trimmed))")
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == true,
let payload = root["payload"] as? [String: Any] else {
throw BmlError.serverError("Account not found")
}
return Self.lookupResult(from: payload, fallbackAccount: trimmed, defaultType: "IAT", defaultBank: nil)
}
private func lookupMibAccount(_ account: String) async throws -> BmlAccountLookupResult {
// Kotlin BmlValidateClient verifies MIB accounts through the Favara account-verification endpoint.
let data = try await apiGet("/api/mobile/favara/account-verification/\(Self.pathComponent(account))/MIB")
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
root["success"] as? Bool == true else {
throw BmlError.serverError("Account not found")
}
let payload = root["payload"] as? [String: Any] ?? root
return Self.lookupResult(from: payload, fallbackAccount: account, defaultType: "DOT", defaultBank: "MIB")
}
private static func lookupResult(
from payload: [String: Any],
fallbackAccount: String,
defaultType: String,
defaultBank: String?
) -> BmlAccountLookupResult {
let trnType = payload["trnType"] as? String
?? payload["transferType"] as? String
?? defaultType
let name = payload["name"] as? String
?? payload["account_name"] as? String
?? payload["accountName"] as? String
?? payload["contact_name"] as? String
?? payload["BfyNm"] as? String
?? ""
let cdtr = payload["CdtrAcct"] as? [String: Any]
let account = cdtr?["Acct"] as? String
?? payload["account"] as? String
?? payload["accountNumber"] as? String
?? payload["benefAccount"] as? String
?? fallbackAccount
let bank = payload["bank"] as? String ?? defaultBank
let bankName = bank == "MIB" ? "Maldives Islamic Bank" : "Bank of Maldives"
return BmlAccountLookupResult(
accountNumber: account,
accountName: name,
bankName: bankName,
trnType: trnType,
bank: bank
)
}
private static func isMibAccount(_ value: String) -> Bool {
value.count == 17 && value.hasPrefix("9") && value.allSatisfy(\.isNumber)
}
private static func pathComponent(_ value: String) -> String {
value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value
}
// MARK: - Step 1: Initiate (no OTP)
func initiateTransfer(
fromAccount: BankAccount,
toAccount: String,
amount: Double,
currency: String,
trnType: String,
bank: String?
) async throws -> BmlTransferInit {
let debitAccount = try debitAccountCode(fromAccount)
var body: [String: Any] = [
"debitAccount": debitAccount,
"creditAccount": toAccount,
"debitAmount": amount,
"transfertype": trnType,
"currency": currency,
"channel": "token"
]
if let bank, !bank.isEmpty {
body["bank"] = bank
}
let data = try await apiPost("/api/mobile/transfer", body: body)
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw BmlError.invalidResponse
}
guard root["success"] as? Bool == true else {
let msg = root["message"] as? String ?? ""
let code = root["code"] as? Int
let extra = (root["payload"] as? [String: Any])?["message"] as? String ?? ""
let full = [msg, extra].filter { !$0.isEmpty }.joined(separator: "")
let suffix = code.map { " (code \($0))" } ?? ""
throw BmlError.serverError(full.isEmpty ? "Transfer failed\(suffix)" : "\(full)\(suffix)")
}
return BmlTransferInit(code: root["code"] as? Int ?? 0)
}
// MARK: - Step 2: Confirm with OTP
func confirmTransfer(
fromAccount: BankAccount,
toAccount: String,
amount: Double,
currency: String,
trnType: String,
remarks: String,
otp: String,
bank: String?
) async throws -> BmlTransferReceipt {
let debitAccount = try debitAccountCode(fromAccount)
var body: [String: Any] = [
"debitAccount": debitAccount,
"creditAccount": toAccount,
"debitAmount": amount,
"transfertype": trnType,
"currency": currency,
"channel": "token",
"otp": otp
]
if !remarks.isEmpty {
body["remarks"] = remarks
}
if let bank, !bank.isEmpty {
body["bank"] = bank
}
let data = try await apiPost("/api/mobile/transfer", body: body)
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw BmlError.invalidResponse
}
guard root["success"] as? Bool == true else {
let msg = root["message"] as? String ?? ""
let code = root["code"] as? Int
let extra = (root["payload"] as? [String: Any])?["message"] as? String ?? ""
let full = [msg, extra].filter { !$0.isEmpty }.joined(separator: "")
let suffix = code.map { " (code \($0))" } ?? ""
throw BmlError.serverError(full.isEmpty ? "Transfer confirmation failed\(suffix)" : "\(full)\(suffix)")
}
let payload = root["payload"] as? [String: Any] ?? [:]
return BmlTransferReceipt(
reference: payload["reference"] as? String ?? "",
timestamp: payload["timestamp"] as? String ?? "",
message: root["message"] as? String ?? "Transfer successful"
)
}
private func debitAccountCode(_ account: BankAccount) throws -> String {
let code = (account.internalId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !code.isEmpty else {
throw BmlError.serverError("BML debit account id missing. Refresh dashboard and select the account again.")
}
return code
}
// MARK: - HTTP
private func apiGet(_ path: String) async throws -> Data {
var req = URLRequest(url: URL(string: "\(base)\(path)")!)
req.setValue("Bearer \(bmlSession.accessToken)", forHTTPHeaderField: "Authorization")
req.setValue(ua, forHTTPHeaderField: "User-Agent")
req.setValue(ver, forHTTPHeaderField: "x-app-version")
let (data, resp) = try await urlSession.data(for: req)
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
if code == 401 || code == 419 { throw BmlError.sessionExpired }
if code >= 500 { throw BmlError.networkError("HTTP \(code)") }
return data
}
private func apiPost(_ path: String, body: [String: Any]) async throws -> Data {
var req = URLRequest(url: URL(string: "\(base)\(path)")!)
req.httpMethod = "POST"
req.setValue("Bearer \(bmlSession.accessToken)", forHTTPHeaderField: "Authorization")
req.setValue(ua, forHTTPHeaderField: "User-Agent")
req.setValue(ver, forHTTPHeaderField: "x-app-version")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
let (data, resp) = try await urlSession.data(for: req)
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
if code == 401 || code == 419 { throw BmlError.sessionExpired }
if code >= 500 { throw BmlError.networkError("HTTP \(code)") }
return data
}
}