Implement bank transfer app flows
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import Foundation
|
||||
|
||||
final class FahipayLoginFlow {
|
||||
|
||||
private let baseURL = "https://fahipay.mv"
|
||||
private let webUA = "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"
|
||||
|
||||
private let cookieStorage = HTTPCookieStorage()
|
||||
private lazy var urlSession: URLSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.httpCookieStorage = cookieStorage
|
||||
cfg.timeoutIntervalForRequest = 30
|
||||
cfg.httpShouldSetCookies = true
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
|
||||
// MARK: - Session cookie management
|
||||
|
||||
func setSessionCookie(_ value: String) {
|
||||
guard let url = URL(string: baseURL),
|
||||
let cookie = HTTPCookie(properties: [
|
||||
.name: "__Secure-sess",
|
||||
.value: value,
|
||||
.domain: "fahipay.mv",
|
||||
.path: "/",
|
||||
.secure: "TRUE"
|
||||
]) else { return }
|
||||
cookieStorage.setCookie(cookie)
|
||||
}
|
||||
|
||||
func getSessionCookieValue() -> String? {
|
||||
cookieStorage.cookies?.first { $0.name == "__Secure-sess" }?.value
|
||||
}
|
||||
|
||||
// MARK: - Login
|
||||
|
||||
/**
|
||||
* Step 1: POST /api/app/login/
|
||||
* Returns FahipayLoginStep:
|
||||
* twoFactorRequired = false + authId set → login complete
|
||||
* twoFactorRequired = true + authId nil → call verifyTotp() next
|
||||
*/
|
||||
func login(idCard: String, password: String, deviceUuid: String) async throws -> FahipayLoginStep {
|
||||
try await initSession()
|
||||
|
||||
let parts: [(String, String)] = [
|
||||
("email", idCard),
|
||||
("password", password),
|
||||
("grant_type", "auth_id"),
|
||||
("lang", "en"),
|
||||
("version", "2.0.0"),
|
||||
("platform", "BasedBank")
|
||||
] + deviceParts(deviceUuid)
|
||||
|
||||
var req = URLRequest(url: URL(string: "\(baseURL)/api/app/login/")!)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue(webUA, forHTTPHeaderField: "User-Agent")
|
||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||
let (contentType, body) = buildMultipartBody(parts: parts)
|
||||
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = body
|
||||
|
||||
let (data, _) = try await urlSession.data(for: req)
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw FahipayError.invalidResponse
|
||||
}
|
||||
guard obj["type"] as? String == "success" else {
|
||||
throw FahipayError.loginFailed(obj["msg"] as? String ?? "Login failed — check your ID card and password")
|
||||
}
|
||||
|
||||
let authId = (obj["authID"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||
let twoFa = obj["two_factor_required"] as? Bool ?? false
|
||||
return FahipayLoginStep(twoFactorRequired: twoFa, authId: authId)
|
||||
}
|
||||
|
||||
// MARK: - TOTP verification
|
||||
|
||||
/**
|
||||
* Step 2 (if 2FA required): POST /api/app/otp/
|
||||
* Returns authId on success.
|
||||
*/
|
||||
func verifyTotp(code: String, deviceUuid: String) async throws -> String {
|
||||
let parts: [(String, String)] = [
|
||||
("code", code),
|
||||
("channel", "totp"),
|
||||
("action", "login"),
|
||||
("grant_type", "auth_id"),
|
||||
("lang", "en"),
|
||||
("version", "2.0.0"),
|
||||
("platform", "BasedBank")
|
||||
] + deviceParts(deviceUuid)
|
||||
|
||||
var req = URLRequest(url: URL(string: "\(baseURL)/api/app/otp/")!)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue(webUA, forHTTPHeaderField: "User-Agent")
|
||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||
let (contentType, body) = buildMultipartBody(parts: parts)
|
||||
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = body
|
||||
|
||||
let (data, _) = try await urlSession.data(for: req)
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw FahipayError.invalidResponse
|
||||
}
|
||||
guard obj["type"] as? String == "success" else {
|
||||
throw FahipayError.otpFailed(obj["msg"] as? String ?? "OTP verification failed")
|
||||
}
|
||||
guard let authId = (obj["authID"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else {
|
||||
throw FahipayError.otpFailed("No authID in OTP response")
|
||||
}
|
||||
return authId
|
||||
}
|
||||
|
||||
// MARK: - Session init
|
||||
|
||||
// Establishes the __Secure-sess cookie required for the login + OTP flow
|
||||
private func initSession() async throws {
|
||||
var req = URLRequest(url: URL(string: "\(baseURL)/api/app/lang/data/")!)
|
||||
req.setValue(webUA, forHTTPHeaderField: "User-Agent")
|
||||
_ = try await urlSession.data(for: req)
|
||||
}
|
||||
|
||||
// MARK: - Device info
|
||||
|
||||
private func deviceParts(_ uuid: String) -> [(String, String)] {
|
||||
[
|
||||
("device[available]", "true"),
|
||||
("device[platform]", "iOS"),
|
||||
("device[uuid]", uuid),
|
||||
("device[model]", "iPhone"),
|
||||
("device[manufacturer]", "Apple"),
|
||||
("device[isVirtual]", "false"),
|
||||
("device[serial]", "unknown")
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Multipart body with lowercase content-disposition headers (Fahipay requirement)
|
||||
|
||||
private func buildMultipartBody(parts: [(String, String)]) -> (contentType: String, body: Data) {
|
||||
let boundary = UUID().uuidString
|
||||
var data = Data()
|
||||
|
||||
for (name, value) in parts {
|
||||
let valueBytes = Data(value.utf8)
|
||||
data += "--\(boundary)\r\n".utf8Data
|
||||
// Fahipay requires lowercase "content-disposition", not standard "Content-Disposition"
|
||||
data += "content-disposition: form-data; name=\"\(name)\"\r\n".utf8Data
|
||||
data += "Content-Length: \(valueBytes.count)\r\n".utf8Data
|
||||
data += "\r\n".utf8Data
|
||||
data += valueBytes
|
||||
data += "\r\n".utf8Data
|
||||
}
|
||||
data += "--\(boundary)--\r\n".utf8Data
|
||||
|
||||
return ("multipart/form-data; boundary=\(boundary)", data)
|
||||
}
|
||||
|
||||
// MARK: - Device UUID generation
|
||||
|
||||
static func generateDeviceUuid() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 8)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, 8, &bytes)
|
||||
return bytes.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var utf8Data: Data { Data(utf8) }
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
static func += (lhs: inout Data, rhs: Data) { lhs.append(rhs) }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
struct FahipaySession: Codable {
|
||||
let authId: String
|
||||
let sessionCookie: String
|
||||
}
|
||||
|
||||
struct FahipayLoginStep {
|
||||
let twoFactorRequired: Bool
|
||||
let authId: String?
|
||||
}
|
||||
|
||||
struct FahipayContactGroup: Codable, Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let contacts: [BankContact]
|
||||
}
|
||||
|
||||
enum FahipayError: LocalizedError {
|
||||
case networkError(String)
|
||||
case loginFailed(String)
|
||||
case otpFailed(String)
|
||||
case sessionExpired
|
||||
case invalidResponse
|
||||
|
||||
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 verification failed: \(msg)"
|
||||
case .sessionExpired: return "Session expired."
|
||||
case .invalidResponse: return "Unexpected server response."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import Foundation
|
||||
|
||||
// Mirrors the Kotlin MibContactsClient exactly.
|
||||
// Endpoint: POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/main
|
||||
// Auth: Cookie header with xxid, IBSID, mbnonce, mbmodel (same session established by MibLoginFlow).
|
||||
actor MibContactsClient {
|
||||
|
||||
private let wvBase = "https://faisamobilex-wv.mib.com.mv"
|
||||
|
||||
private let urlSession: URLSession = {
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.timeoutIntervalForRequest = 30
|
||||
cfg.timeoutIntervalForResource = 60
|
||||
cfg.httpCookieAcceptPolicy = .never
|
||||
cfg.httpShouldSetCookies = false
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
|
||||
private let session: MibSession
|
||||
|
||||
init(session: MibSession) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func fetchContacts(loginTag: String) async throws -> [BankContact] {
|
||||
var all: [BankContact] = []
|
||||
var page = 1
|
||||
let pageSize = 100
|
||||
|
||||
while true {
|
||||
let start = (page - 1) * pageSize + 1
|
||||
let end = page * pageSize
|
||||
|
||||
let formFields: [(String, String)] = [
|
||||
("page", String(page)),
|
||||
("search", ""),
|
||||
("searchCategoryId", "0"),
|
||||
("benefType", "A"),
|
||||
("sortBenef", "name"),
|
||||
("sortDir", "asc"),
|
||||
("start", String(start)),
|
||||
("end", String(end)),
|
||||
("includeCount", "1"),
|
||||
]
|
||||
|
||||
let (contacts, totalCount) = try await fetchPage(formFields: formFields, loginTag: loginTag)
|
||||
all.append(contentsOf: contacts)
|
||||
if all.count >= totalCount || contacts.isEmpty { break }
|
||||
page += 1
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func fetchPage(formFields: [(String, String)], loginTag: String) async throws -> ([BankContact], Int) {
|
||||
let url = URL(string: "\(wvBase)/ajaxBeneficiary/main")!
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
||||
request.setValue(
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
|
||||
forHTTPHeaderField: "User-Agent"
|
||||
)
|
||||
request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
|
||||
request.setValue("*/*", forHTTPHeaderField: "Accept")
|
||||
request.setValue(wvBase, forHTTPHeaderField: "Origin")
|
||||
request.setValue("\(wvBase)/beneficiary?dashurl=1", forHTTPHeaderField: "Referer")
|
||||
|
||||
var allowed = CharacterSet.alphanumerics
|
||||
allowed.insert(charactersIn: "-._~")
|
||||
let body = formFields.map { k, v in
|
||||
"\(k.addingPercentEncoding(withAllowedCharacters: allowed) ?? k)=\(v.addingPercentEncoding(withAllowedCharacters: allowed) ?? v)"
|
||||
}.joined(separator: "&")
|
||||
request.httpBody = Data(body.utf8)
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if code == 419 { throw MibError.sessionExpired }
|
||||
if code >= 500 { throw MibError.networkError("HTTP \(code)") }
|
||||
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw MibError.invalidResponse
|
||||
}
|
||||
|
||||
let totalCount = obj["total_count"] as? Int
|
||||
?? Int(obj["total_count"] as? String ?? "") ?? 0
|
||||
|
||||
guard let arr = obj["data"] as? [[String: Any]] else {
|
||||
return ([], totalCount)
|
||||
}
|
||||
|
||||
let contacts = arr.compactMap { d -> BankContact? in
|
||||
let account = d["benefAccount"] as? String ?? ""
|
||||
guard !account.isEmpty else { return nil }
|
||||
|
||||
let benefNo = d["benefNo"] as? String ?? account
|
||||
// Android: benefType "I"=Internal(MIB), "L"=Local(IPS), "S"=Swift
|
||||
let rawType = d["benefType"] as? String ?? "I"
|
||||
let benefType: String
|
||||
switch rawType {
|
||||
case "I": benefType = "MIB"
|
||||
case "L": benefType = "LOCAL"
|
||||
case "S": benefType = "SWIFT"
|
||||
default: benefType = rawType
|
||||
}
|
||||
|
||||
return BankContact(
|
||||
id: "MIB_\(benefNo)_\(loginTag)",
|
||||
benefNo: benefNo,
|
||||
benefName: d["benefName"] as? String ?? "",
|
||||
benefNickName: (d["benefNickName"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||||
benefAccount: account,
|
||||
benefType: benefType,
|
||||
bankColor: d["bankColor"] as? String,
|
||||
benefBankName: d["benefBankName"] as? String,
|
||||
bankCode: d["bankCode"] as? String,
|
||||
benefStatus: d["benefStatus"] as? String ?? "Active",
|
||||
transferCyDesc: d["transferCyDesc"] as? String ?? "MVR",
|
||||
customerImgHash: (d["customerImgHash"] as? String).flatMap { $0.isEmpty || $0 == "null" ? nil : $0 },
|
||||
benefCategoryId: d["benefCategoryID"] as? String,
|
||||
profileId: nil,
|
||||
source: "MIB"
|
||||
)
|
||||
}
|
||||
|
||||
return (contacts, totalCount)
|
||||
}
|
||||
|
||||
private var cookieHeader: String {
|
||||
"mbmodel=IOS-1.0; xxid=\(session.xxid); IBSID=\(session.xxid); mbnonce=\(session.nonceGenerator); time-tracker=597"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import CommonCrypto
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - BigUInt
|
||||
|
||||
struct BigUInt {
|
||||
private(set) var words: [UInt32] // little-endian (index 0 = least significant)
|
||||
|
||||
static let zero = BigUInt(words: [0])
|
||||
static let one = BigUInt(words: [1])
|
||||
|
||||
init(words: [UInt32]) {
|
||||
self.words = words
|
||||
normalize()
|
||||
}
|
||||
|
||||
init(decimal string: String) {
|
||||
var result = BigUInt.zero
|
||||
let ten = BigUInt(words: [10])
|
||||
for ch in string {
|
||||
guard let d = ch.wholeNumberValue else { continue }
|
||||
result = result.multiplied(by: ten)
|
||||
result = result.adding(BigUInt(words: [UInt32(d)]))
|
||||
}
|
||||
self = result
|
||||
}
|
||||
|
||||
private mutating func normalize() {
|
||||
while words.count > 1 && words.last == 0 { words.removeLast() }
|
||||
}
|
||||
|
||||
var isZero: Bool { words.count == 1 && words[0] == 0 }
|
||||
var isOdd: Bool { words[0] & 1 == 1 }
|
||||
|
||||
var bitWidth: Int {
|
||||
let n = words.count
|
||||
guard n > 0 else { return 0 }
|
||||
let top = words[n - 1]
|
||||
if top == 0 { return max(0, (n - 1) * 32) }
|
||||
return (n - 1) * 32 + (32 - top.leadingZeroBitCount)
|
||||
}
|
||||
|
||||
func bit(_ index: Int) -> Bool {
|
||||
let word = index / 32
|
||||
let bit = index % 32
|
||||
guard word < words.count else { return false }
|
||||
return (words[word] >> bit) & 1 == 1
|
||||
}
|
||||
|
||||
// MARK: Comparison
|
||||
|
||||
static func < (lhs: BigUInt, rhs: BigUInt) -> Bool {
|
||||
if lhs.words.count != rhs.words.count {
|
||||
return lhs.words.count < rhs.words.count
|
||||
}
|
||||
for i in stride(from: lhs.words.count - 1, through: 0, by: -1) {
|
||||
if lhs.words[i] != rhs.words[i] { return lhs.words[i] < rhs.words[i] }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func == (lhs: BigUInt, rhs: BigUInt) -> Bool { lhs.words == rhs.words }
|
||||
|
||||
// MARK: Addition
|
||||
|
||||
func adding(_ other: BigUInt) -> BigUInt {
|
||||
let maxLen = max(words.count, other.words.count)
|
||||
var result = [UInt32](repeating: 0, count: maxLen + 1)
|
||||
var carry: UInt64 = 0
|
||||
for i in 0..<maxLen {
|
||||
let a: UInt64 = i < words.count ? UInt64(words[i]) : 0
|
||||
let b: UInt64 = i < other.words.count ? UInt64(other.words[i]) : 0
|
||||
let sum = a + b + carry
|
||||
result[i] = UInt32(sum & 0xFFFFFFFF)
|
||||
carry = sum >> 32
|
||||
}
|
||||
result[maxLen] = UInt32(carry)
|
||||
return BigUInt(words: result)
|
||||
}
|
||||
|
||||
// MARK: Subtraction (self >= other assumed)
|
||||
|
||||
func subtracting(_ other: BigUInt) -> BigUInt {
|
||||
var result = words
|
||||
var borrow: Int64 = 0
|
||||
for i in 0..<result.count {
|
||||
let b: Int64 = i < other.words.count ? Int64(other.words[i]) : 0
|
||||
let diff = Int64(result[i]) - b - borrow
|
||||
if diff < 0 {
|
||||
result[i] = UInt32(bitPattern: Int32(truncatingIfNeeded: diff &+ 0x1_0000_0000))
|
||||
borrow = 1
|
||||
} else {
|
||||
result[i] = UInt32(diff)
|
||||
borrow = 0
|
||||
}
|
||||
}
|
||||
return BigUInt(words: result)
|
||||
}
|
||||
|
||||
// MARK: Multiplication
|
||||
|
||||
func multiplied(by other: BigUInt) -> BigUInt {
|
||||
let n = words.count
|
||||
let m = other.words.count
|
||||
var result = [UInt32](repeating: 0, count: n + m)
|
||||
for i in 0..<n {
|
||||
var carry: UInt64 = 0
|
||||
for j in 0..<m {
|
||||
let prod = UInt64(words[i]) * UInt64(other.words[j]) + UInt64(result[i + j]) + carry
|
||||
result[i + j] = UInt32(prod & 0xFFFFFFFF)
|
||||
carry = prod >> 32
|
||||
}
|
||||
result[i + m] += UInt32(carry)
|
||||
}
|
||||
return BigUInt(words: result)
|
||||
}
|
||||
|
||||
// MARK: Modulo (binary shift-subtract)
|
||||
|
||||
func modulo(_ divisor: BigUInt) -> BigUInt {
|
||||
if divisor.isZero { return .zero }
|
||||
if self < divisor { return self }
|
||||
|
||||
var remainder = BigUInt.zero
|
||||
let totalBits = bitWidth
|
||||
|
||||
for i in stride(from: totalBits - 1, through: 0, by: -1) {
|
||||
remainder = remainder.shiftedLeft1()
|
||||
if bit(i) {
|
||||
remainder.words[0] |= 1
|
||||
}
|
||||
if !(remainder < divisor) {
|
||||
remainder = remainder.subtracting(divisor)
|
||||
}
|
||||
}
|
||||
return remainder
|
||||
}
|
||||
|
||||
private func shiftedLeft1() -> BigUInt {
|
||||
var result = [UInt32](repeating: 0, count: words.count + 1)
|
||||
var carry: UInt32 = 0
|
||||
for i in 0..<words.count {
|
||||
let shifted = (UInt64(words[i]) << 1) | UInt64(carry)
|
||||
result[i] = UInt32(shifted & 0xFFFFFFFF)
|
||||
carry = UInt32(shifted >> 32)
|
||||
}
|
||||
result[words.count] = carry
|
||||
return BigUInt(words: result)
|
||||
}
|
||||
|
||||
// MARK: ModPow — right-to-left binary
|
||||
|
||||
func modPow(exp: BigUInt, mod: BigUInt) -> BigUInt {
|
||||
if mod == .one { return .zero }
|
||||
var result = BigUInt.one
|
||||
var base = modulo(mod)
|
||||
var e = exp
|
||||
|
||||
while !e.isZero {
|
||||
if e.isOdd {
|
||||
result = result.multiplied(by: base).modulo(mod)
|
||||
}
|
||||
e = e.shiftedRight1()
|
||||
base = base.multiplied(by: base).modulo(mod)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func shiftedRight1() -> BigUInt {
|
||||
var result = words
|
||||
var borrow: UInt32 = 0
|
||||
for i in stride(from: result.count - 1, through: 0, by: -1) {
|
||||
let new = (result[i] >> 1) | (borrow << 31)
|
||||
borrow = result[i] & 1
|
||||
result[i] = new
|
||||
}
|
||||
return BigUInt(words: result)
|
||||
}
|
||||
|
||||
// MARK: Decimal String (O(n) per digit via UInt64 carry)
|
||||
|
||||
var decimalString: String {
|
||||
if isZero { return "0" }
|
||||
var digits: [Character] = []
|
||||
var remaining = words
|
||||
|
||||
while !(remaining.count == 1 && remaining[0] == 0) {
|
||||
var rem: UInt64 = 0
|
||||
for i in stride(from: remaining.count - 1, through: 0, by: -1) {
|
||||
let cur = (rem << 32) | UInt64(remaining[i])
|
||||
remaining[i] = UInt32(cur / 10)
|
||||
rem = cur % 10
|
||||
}
|
||||
digits.append(Character(String(rem)))
|
||||
while remaining.count > 1 && remaining.last == 0 { remaining.removeLast() }
|
||||
}
|
||||
return String(digits.reversed())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MibCrypto
|
||||
|
||||
enum MibCrypto {
|
||||
static let defaultKey = "8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678"
|
||||
|
||||
// DH exponent A
|
||||
private static let dhA = BigUInt(decimal: "1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577")
|
||||
|
||||
// DH modulus P
|
||||
private static let dhP = BigUInt(decimal: "2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919")
|
||||
|
||||
// Pre-computed: 2^A mod P (sent to server in every DH key exchange)
|
||||
static let cmod = "2301533261465719294935473752300816828871347570489984010719302185643154930578030858802510266223676458248149736055131586081546441805822467879511077637292661430926459449504165353261883687521672133746481188567585259995381323120449147180627189549882525129015625540437713248150022924125681681822594907090716721855161928482983925605401801685462643917325004120294159320617793870605573541358655167992228254033496751384810855107557385690184061912872019448968632814193704749"
|
||||
|
||||
// Derives session key: Base64(SHA256(smod^A mod P as decimal string))
|
||||
static func deriveSessionKey(_ smod: String) -> String {
|
||||
let smodInt = BigUInt(decimal: smod)
|
||||
let shared = smodInt.modPow(exp: dhA, mod: dhP)
|
||||
let secretStr = shared.decimalString
|
||||
let sha = SHA256.hash(data: Data(secretStr.utf8))
|
||||
return Data(sha).base64EncodedString()
|
||||
}
|
||||
|
||||
// MARK: Blowfish/ECB/PKCS7 Encrypt
|
||||
|
||||
static func encrypt(_ json: [String: Any], key: String) throws -> String {
|
||||
guard let jsonData = try? JSONSerialization.data(withJSONObject: json) else {
|
||||
throw MibError.encryptionFailed
|
||||
}
|
||||
let keyBytes = key.data(using: .isoLatin1) ?? Data(key.utf8)
|
||||
let encrypted = try blowfishProcess(data: jsonData, key: keyBytes, operation: CCOperation(kCCEncrypt))
|
||||
return encrypted.base64EncodedString()
|
||||
}
|
||||
|
||||
static func decrypt(_ base64: String, key: String) throws -> [String: Any] {
|
||||
guard let cipherData = Data(base64Encoded: base64, options: .ignoreUnknownCharacters) else {
|
||||
throw MibError.decryptionFailed
|
||||
}
|
||||
let keyBytes = key.data(using: .isoLatin1) ?? Data(key.utf8)
|
||||
let plainData = try blowfishProcess(data: cipherData, key: keyBytes, operation: CCOperation(kCCDecrypt))
|
||||
guard let json = try? JSONSerialization.jsonObject(with: plainData) as? [String: Any] else {
|
||||
throw MibError.decryptionFailed
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
private static func blowfishProcess(data: Data, key: Data, operation: CCOperation) throws -> Data {
|
||||
var outLen = 0
|
||||
let bufLen = data.count + kCCBlockSizeBlowfish
|
||||
var outBuf = [UInt8](repeating: 0, count: bufLen)
|
||||
|
||||
let status = data.withUnsafeBytes { dataPtr in
|
||||
key.withUnsafeBytes { keyPtr in
|
||||
CCCrypt(
|
||||
operation,
|
||||
CCAlgorithm(kCCAlgorithmBlowfish),
|
||||
CCOptions(kCCOptionECBMode | kCCOptionPKCS7Padding),
|
||||
keyPtr.baseAddress, key.count,
|
||||
nil,
|
||||
dataPtr.baseAddress, data.count,
|
||||
&outBuf, bufLen,
|
||||
&outLen
|
||||
)
|
||||
}
|
||||
}
|
||||
guard status == kCCSuccess else {
|
||||
throw operation == CCOperation(kCCEncrypt) ? MibError.encryptionFailed : MibError.decryptionFailed
|
||||
}
|
||||
return Data(outBuf[0..<outLen])
|
||||
}
|
||||
|
||||
// MARK: SHA-256 helpers
|
||||
|
||||
static func hashPassword(_ password: String) -> String {
|
||||
sha256Upper(password)
|
||||
}
|
||||
|
||||
static func sha256Upper(_ input: String) -> String {
|
||||
let hash = SHA256.hash(data: Data(input.utf8))
|
||||
return hash.map { String(format: "%02X", $0) }.joined()
|
||||
}
|
||||
|
||||
// pgf03 = SHA256(clientSalt + SHA256(passwordHash + userSalt))
|
||||
static func computePgf03(passwordHash: String, userSalt: String, clientSalt: String) -> String {
|
||||
let inner = sha256Upper(passwordHash + userSalt)
|
||||
return sha256Upper(clientSalt + inner)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
// Mirrors Kotlin MibFinancingClient exactly.
|
||||
// Fetches the /financing?dashurl=1 HTML page and parses finance-card-holder data attributes.
|
||||
actor MibFinancingClient {
|
||||
|
||||
private let wvBase = "https://faisamobilex-wv.mib.com.mv"
|
||||
private let session: MibSession
|
||||
|
||||
private let urlSession: URLSession = {
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.timeoutIntervalForRequest = 30
|
||||
cfg.timeoutIntervalForResource = 60
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
|
||||
init(session: MibSession) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func fetchFinancing() async throws -> [MibFinanceDeal] {
|
||||
var req = URLRequest(url: URL(string: "\(wvBase)/financing?dashurl=1")!)
|
||||
req.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
||||
req.setValue(
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
|
||||
forHTTPHeaderField: "User-Agent"
|
||||
)
|
||||
req.setValue("mv.com.mib.faisamobilex", forHTTPHeaderField: "X-Requested-With")
|
||||
|
||||
let (data, response) = try await urlSession.data(for: req)
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if code == 419 { throw MibError.sessionExpired }
|
||||
if code >= 500 { throw MibError.networkError("HTTP \(code)") }
|
||||
|
||||
guard let html = String(data: data, encoding: .utf8) else { return [] }
|
||||
return parseHtml(html)
|
||||
}
|
||||
|
||||
// MARK: - HTML parsing (mirrors Kotlin regex approach)
|
||||
|
||||
private func parseHtml(_ html: String) -> [MibFinanceDeal] {
|
||||
guard let cardRe = try? NSRegularExpression(pattern: #"finance-card-holder[^>]+>"#),
|
||||
let attrRe = try? NSRegularExpression(pattern: #"data-(\w+)\s*=\s*"([^"]*)""#) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let ns = html as NSString
|
||||
return cardRe.matches(in: html, range: NSRange(location: 0, length: ns.length)).compactMap { m in
|
||||
let cardStr = ns.substring(with: m.range)
|
||||
let cardNS = cardStr as NSString
|
||||
var attrs: [String: String] = [:]
|
||||
attrRe.matches(in: cardStr, range: NSRange(location: 0, length: cardNS.length)).forEach { a in
|
||||
let key = cardNS.substring(with: a.range(at: 1))
|
||||
let val = cardNS.substring(with: a.range(at: 2))
|
||||
attrs[key] = val
|
||||
}
|
||||
guard let dealNo = attrs["dealNo"] else { return nil }
|
||||
return MibFinanceDeal(
|
||||
dealNo: dealNo,
|
||||
productDesc: attrs["productDesc"] ?? "",
|
||||
dealStatus: attrs["dealStatus"] ?? "",
|
||||
statusDesc: attrs["statusDesc"] ?? "",
|
||||
dealAmount: Double(attrs["dealAmount"] ?? "") ?? 0,
|
||||
paidAmount: Double(attrs["paidAmount"] ?? "") ?? 0,
|
||||
outstandingAmount: Double(attrs["outstandingAmount"] ?? "") ?? 0,
|
||||
dealDate: attrs["dealDate"] ?? "",
|
||||
overdueAmount: Double(attrs["overdueAmount"] ?? "") ?? 0,
|
||||
installmentAmount: Double(attrs["installmentAmount"] ?? "") ?? 0,
|
||||
noOfInstallments: Int(attrs["noOfInstallments"] ?? "") ?? 0,
|
||||
lastPaidDate: attrs["lastPaidDate"] ?? "",
|
||||
lastPayAmount: Double(attrs["lastPayAmount"] ?? "") ?? 0,
|
||||
currency: attrs["curCodeDesc"] ?? "MVR"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var cookieHeader: String {
|
||||
"mbmodel=IOS-1.0; xxid=\(session.xxid); IBSID=\(session.xxid); mbnonce=\(session.nonceGenerator); time-tracker=597"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import Foundation
|
||||
|
||||
private struct SessionExpiredError: Error {}
|
||||
|
||||
actor MibLoginFlow {
|
||||
|
||||
private let baseURL = URL(string: "https://faisanet.mib.com.mv/faisamobilex_smvc/")!
|
||||
|
||||
private(set) var lastSession: MibSession?
|
||||
private(set) var lastProfiles: [MibProfile] = []
|
||||
var onSessionRefreshed: (@Sendable (MibSession, [MibProfile]) -> Void)?
|
||||
|
||||
private var loginId: String = ""
|
||||
private var storedUsername: String?
|
||||
private var storedPasswordHash: String?
|
||||
private var storedOtpSeed: String?
|
||||
private var isRelogging = false
|
||||
|
||||
private let urlSession: URLSession = {
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.timeoutIntervalForRequest = 30
|
||||
cfg.timeoutIntervalForResource = 60
|
||||
cfg.httpCookieAcceptPolicy = .never
|
||||
cfg.httpShouldSetCookies = false
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
|
||||
// MARK: - Public entry point
|
||||
|
||||
func login(username: String, passwordHash: String, otpSeed: String) async throws -> [BankAccount] {
|
||||
loginId = username
|
||||
storedUsername = username
|
||||
storedPasswordHash = passwordHash
|
||||
storedOtpSeed = otpSeed
|
||||
|
||||
let appId = getOrCreateAppId(for: username)
|
||||
let key1 = credentialString("mib_\(username)_enc_key1")
|
||||
let key2 = credentialString("mib_\(username)_enc_key2")
|
||||
|
||||
if let k1 = key1, let k2 = key2 {
|
||||
return try await regularLogin(username: username, passwordHash: passwordHash, appId: appId, key1: k1, key2: k2)
|
||||
} else {
|
||||
return try await firstTimeRegistration(username: username, passwordHash: passwordHash, otpSeed: otpSeed, appId: appId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - First-time registration
|
||||
|
||||
private func firstTimeRegistration(
|
||||
username: String, passwordHash: String, otpSeed: String, appId: String
|
||||
) async throws -> [BankAccount] {
|
||||
let (session1, _) = try await initialKeyExchange(appId: appId, encKey: MibCrypto.defaultKey, sfunc: "r")
|
||||
|
||||
let userSalt = try await getAuthType(session: session1, username: username)
|
||||
let clientSalt = MibNonce.randomAlpha(32)
|
||||
let pgf03 = MibCrypto.computePgf03(passwordHash: passwordHash, userSalt: userSalt, clientSalt: clientSalt)
|
||||
|
||||
var regPayload = baseData(session: session1, routePath: "C41")
|
||||
regPayload["uname"] = username
|
||||
regPayload["pgf03"] = pgf03
|
||||
regPayload["clientSalt"] = clientSalt
|
||||
|
||||
let regResp = try await doRequest(session: session1, data: regPayload, sfunc: "n")
|
||||
guard regResp["success"] as? Bool == true else {
|
||||
throw MibError.serverError(regResp["reasonText"] as? String ?? "Registration init failed")
|
||||
}
|
||||
|
||||
let otp = Totp.generate(otpSeed)
|
||||
var otpPayload = baseData(session: session1, routePath: "C42")
|
||||
otpPayload["otp"] = otp
|
||||
otpPayload["uname"] = username
|
||||
otpPayload["otpType"] = "3"
|
||||
|
||||
let otpResp = try await doRequest(session: session1, data: otpPayload, sfunc: "n")
|
||||
guard otpResp["success"] as? Bool == true else {
|
||||
throw MibError.serverError(otpResp["reasonText"] as? String ?? "OTP verification failed")
|
||||
}
|
||||
|
||||
guard let dataArr = otpResp["data"] as? [[String: Any]],
|
||||
let keyData = dataArr.first,
|
||||
let key1 = keyData["key1"] as? String,
|
||||
let key2 = keyData["key2"] as? String else {
|
||||
throw MibError.invalidResponse
|
||||
}
|
||||
|
||||
try? CredentialStore.shared.save(key1, forKey: "mib_\(username)_enc_key1")
|
||||
try? CredentialStore.shared.save(key2, forKey: "mib_\(username)_enc_key2")
|
||||
|
||||
return try await regularLogin(username: username, passwordHash: passwordHash, appId: appId, key1: key1, key2: key2)
|
||||
}
|
||||
|
||||
// MARK: - Regular login
|
||||
|
||||
private func regularLogin(
|
||||
username: String, passwordHash: String,
|
||||
appId: String, key1: String, key2: String
|
||||
) async throws -> [BankAccount] {
|
||||
let (session2, _) = try await initialKeyExchange(appId: appId, encKey: key1, sfunc: "i", key2: key2)
|
||||
|
||||
let userSalt = try await getAuthType(session: session2, username: username)
|
||||
let clientSalt = MibNonce.randomAlpha(32)
|
||||
let pgf03 = MibCrypto.computePgf03(passwordHash: passwordHash, userSalt: userSalt, clientSalt: clientSalt)
|
||||
|
||||
var loginPayload = baseData(session: session2, routePath: "A41")
|
||||
loginPayload["uname"] = username
|
||||
loginPayload["pgf03"] = pgf03
|
||||
loginPayload["clientSalt"] = clientSalt
|
||||
loginPayload["pmodTime"] = 0
|
||||
loginPayload["requireBankData"] = 1
|
||||
|
||||
let loginResp = try await doRequest(session: session2, data: loginPayload, sfunc: "n")
|
||||
guard loginResp["success"] as? Bool == true else {
|
||||
throw MibError.serverError(loginResp["reasonText"] as? String ?? "Login failed")
|
||||
}
|
||||
|
||||
let profiles = parseProfiles(from: loginResp)
|
||||
lastSession = session2
|
||||
lastProfiles = profiles
|
||||
|
||||
// Single-profile fast path: server returned balances directly in A41
|
||||
if loginResp["profileSelected"] as? Bool == true,
|
||||
let balances = loginResp["accountBalance"] as? [[String: Any]], !balances.isEmpty {
|
||||
let selectedId = loginResp["selectedProfileId"] as? String ?? ""
|
||||
if let profile = profiles.first(where: { $0.profileId == selectedId }) ?? profiles.first {
|
||||
return balances.map { makeAccount(from: $0, profile: profile, loginTag: "mib_\(username)") }
|
||||
+ makeCards(from: loginResp, profile: profile, loginTag: "mib_\(username)")
|
||||
}
|
||||
}
|
||||
|
||||
let hidden = hiddenProfileIds(for: username)
|
||||
let visible = hidden.isEmpty ? profiles : profiles.filter { !hidden.contains($0.profileId) }
|
||||
return try await fetchAllProfiles(session: session2, profiles: visible, loginTag: "mib_\(username)")
|
||||
}
|
||||
|
||||
// MARK: - Profile fetching
|
||||
|
||||
func fetchAllProfiles(session: MibSession, profiles: [MibProfile], loginTag: String) async throws -> [BankAccount] {
|
||||
var allAccounts: [BankAccount] = []
|
||||
for profile in profiles {
|
||||
var payload = baseData(session: session, routePath: "P47")
|
||||
payload["profileType"] = profile.profileType
|
||||
payload["profileId"] = profile.profileId
|
||||
let resp = try await doRequest(session: session, data: payload, sfunc: "n")
|
||||
guard resp["success"] as? Bool == true,
|
||||
let balances = resp["accountBalance"] as? [[String: Any]] else { continue }
|
||||
allAccounts += balances.map { makeAccount(from: $0, profile: profile, loginTag: loginTag) }
|
||||
allAccounts += makeCards(from: resp, profile: profile, loginTag: loginTag)
|
||||
}
|
||||
return allAccounts
|
||||
}
|
||||
|
||||
func switchProfile(session: MibSession, profile: MibProfile) async throws {
|
||||
var payload = baseData(session: session, routePath: "P47")
|
||||
payload["profileType"] = profile.profileType
|
||||
payload["profileId"] = profile.profileId
|
||||
_ = try await doRequest(session: session, data: payload, sfunc: "n")
|
||||
}
|
||||
|
||||
func fetchProfileImage(session: MibSession, imageHash: String) async throws -> String? {
|
||||
var payload = baseData(session: session, routePath: "P41")
|
||||
payload["imageHash"] = imageHash
|
||||
let resp = try await doRequest(session: session, data: payload, sfunc: "n")
|
||||
guard resp["success"] as? Bool == true else { return nil }
|
||||
return (resp["profileImage"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||
}
|
||||
|
||||
// MARK: - DH Key Exchange
|
||||
|
||||
private func initialKeyExchange(
|
||||
appId: String, encKey: String, sfunc: String, key2: String? = nil
|
||||
) async throws -> (MibSession, String) {
|
||||
let innerPayload: [String: Any] = [
|
||||
"cmod": MibCrypto.cmod,
|
||||
"appId": appId,
|
||||
"routePath": "S40",
|
||||
"sodium": MibNonce.randomSodium(),
|
||||
"xxid": MibNonce.randomXxid()
|
||||
]
|
||||
|
||||
let encrypted = try MibCrypto.encrypt(innerPayload, key: encKey)
|
||||
var formFields = [("sfunc", sfunc), ("data", encrypted)]
|
||||
if let k2 = key2 { formFields.append(("key2", k2)) }
|
||||
|
||||
let responseStr = try await postForm(fields: formFields)
|
||||
|
||||
// Server may return a plain JSON error (not Blowfish-encrypted) for auth/config failures.
|
||||
let trimmed = responseStr.trimmingCharacters(in: .whitespaces)
|
||||
let respJson: [String: Any]
|
||||
if trimmed.hasPrefix("{"),
|
||||
let plainObj = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8)) as? [String: Any] {
|
||||
respJson = plainObj
|
||||
} else {
|
||||
respJson = try MibCrypto.decrypt(responseStr, key: encKey)
|
||||
}
|
||||
|
||||
guard respJson["success"] as? Bool == true else {
|
||||
throw MibError.serverError(respJson["reasonText"] as? String ?? "Key exchange failed")
|
||||
}
|
||||
|
||||
let smod = respJson["smod"] as? String ?? ""
|
||||
let xxid = respJson["xxid"] as? String ?? ""
|
||||
let nonceGen = respJson["nonceGenerator"] as? String ?? ""
|
||||
let sessionKey = MibCrypto.deriveSessionKey(smod)
|
||||
|
||||
return (MibSession(appId: appId, xxid: xxid, nonceGenerator: nonceGen, sessionKey: sessionKey), xxid)
|
||||
}
|
||||
|
||||
private func getAuthType(session: MibSession, username: String) async throws -> String {
|
||||
var payload = baseData(session: session, routePath: "A44")
|
||||
payload["uname"] = username
|
||||
let resp = try await doRequest(session: session, data: payload, sfunc: "n")
|
||||
guard let arr = resp["data"] as? [[String: Any]],
|
||||
let salt = arr.first?["userSalt"] as? String else { throw MibError.invalidResponse }
|
||||
return salt
|
||||
}
|
||||
|
||||
// MARK: - Request engine with session recovery
|
||||
|
||||
private func doRequest(session: MibSession, data: [String: Any], sfunc: String) async throws -> [String: Any] {
|
||||
do {
|
||||
return try await sendRequest(session: session, data: data, sfunc: sfunc)
|
||||
} catch is SessionExpiredError {
|
||||
guard !isRelogging else { throw MibError.sessionExpired }
|
||||
isRelogging = true
|
||||
defer { isRelogging = false }
|
||||
|
||||
guard let u = storedUsername, let ph = storedPasswordHash, let os = storedOtpSeed else {
|
||||
throw MibError.sessionExpired
|
||||
}
|
||||
_ = try await login(username: u, passwordHash: ph, otpSeed: os)
|
||||
guard let newSession = lastSession else { throw MibError.sessionExpired }
|
||||
onSessionRefreshed?(newSession, lastProfiles)
|
||||
|
||||
var retryData = data
|
||||
retryData["nonce"] = MibNonce.generate(newSession.nonceGenerator)
|
||||
retryData["appId"] = newSession.appId
|
||||
retryData["sodium"] = MibNonce.randomSodium()
|
||||
retryData["xxid"] = newSession.xxid
|
||||
return try await sendRequest(session: newSession, data: retryData, sfunc: sfunc)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendRequest(session: MibSession, data: [String: Any], sfunc: String) async throws -> [String: Any] {
|
||||
let encrypted = try MibCrypto.encrypt(data, key: session.sessionKey)
|
||||
let formFields = [("xxid", session.xxid), ("sfunc", sfunc), ("data", encrypted)]
|
||||
let responseStr = try await postForm(fields: formFields)
|
||||
|
||||
let trimmed = responseStr.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty { throw SessionExpiredError() }
|
||||
|
||||
if trimmed.hasPrefix("{"),
|
||||
let obj = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8)) as? [String: Any] {
|
||||
if obj["reasonCode"] as? String == "505" { throw SessionExpiredError() }
|
||||
return obj
|
||||
}
|
||||
return try MibCrypto.decrypt(responseStr, key: session.sessionKey)
|
||||
}
|
||||
|
||||
// MARK: - HTTP
|
||||
|
||||
private func postForm(fields: [(String, String)]) async throws -> String {
|
||||
var request = URLRequest(url: baseURL)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("android/1.0", forHTTPHeaderField: "User-Agent")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
|
||||
// RFC 3986 unreserved chars only — ensures + in base64 values is encoded as %2B.
|
||||
// .urlQueryAllowed leaves + unencoded; servers parsing application/x-www-form-urlencoded
|
||||
// decode + as a space, silently corrupting any base64 ciphertext that contains it.
|
||||
var formValueAllowed = CharacterSet.alphanumerics
|
||||
formValueAllowed.insert(charactersIn: "-._~")
|
||||
let body = fields.map { k, v in
|
||||
let ek = k.addingPercentEncoding(withAllowedCharacters: formValueAllowed) ?? k
|
||||
let ev = v.addingPercentEncoding(withAllowedCharacters: formValueAllowed) ?? v
|
||||
return "\(ek)=\(ev)"
|
||||
}.joined(separator: "&")
|
||||
request.httpBody = Data(body.utf8)
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw MibError.networkError("No HTTP response") }
|
||||
|
||||
if http.statusCode == 419 { throw SessionExpiredError() }
|
||||
if http.statusCode >= 500 { throw MibError.networkError("Server error HTTP \(http.statusCode)") }
|
||||
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func baseData(session: MibSession, routePath: String) -> [String: Any] {
|
||||
[
|
||||
"nonce": MibNonce.generate(session.nonceGenerator),
|
||||
"appId": session.appId,
|
||||
"sodium": MibNonce.randomSodium(),
|
||||
"routePath": routePath,
|
||||
"xxid": session.xxid
|
||||
]
|
||||
}
|
||||
|
||||
private func parseProfiles(from json: [String: Any]) -> [MibProfile] {
|
||||
guard let arr = json["operatingProfiles"] as? [[String: Any]] else { return [] }
|
||||
return arr.map { p in
|
||||
MibProfile(
|
||||
profileId: p["profileId"] as? String ?? "",
|
||||
customerProfileId: p["customerProfileId"] as? String ?? "",
|
||||
annexId: p["annexId"] as? String ?? "",
|
||||
customerId: p["customerId"] as? String ?? "",
|
||||
name: p["name"] as? String ?? "",
|
||||
cifType: p["cifType"] as? String ?? "",
|
||||
profileType: p["profileType"] as? String ?? "",
|
||||
color: p["color"] as? String ?? "",
|
||||
customerImage: (p["customerImage"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeAccount(from a: [String: Any], profile: MibProfile, loginTag: String) -> BankAccount {
|
||||
let accNum = a["accountNumber"] as? String ?? ""
|
||||
let rawBlocked = a["blockedAmount"] as? String ?? "0.00"
|
||||
return BankAccount(
|
||||
id: "MIB_\(accNum)_\(loginTag)",
|
||||
bank: "MIB",
|
||||
profileName: profile.name,
|
||||
profileType: profile.profileType,
|
||||
productCode: profile.cifType,
|
||||
accountNumber: accNum,
|
||||
accountBriefName: a["accountBriefName"] as? String ?? "",
|
||||
currencyName: a["currencyName"] as? String ?? "MVR",
|
||||
accountTypeName: a["accountTypeName"] as? String ?? "",
|
||||
availableBalance: Double(a["availableBalance"] as? String ?? "") ?? 0,
|
||||
currentBalance: Double(a["currentBalance"] as? String ?? "") ?? 0,
|
||||
blockedAmount: abs(Double(rawBlocked) ?? 0),
|
||||
mvrBalance: Double(a["mvrBalance"] as? String ?? ""),
|
||||
statusDesc: a["statusDesc"] as? String ?? "",
|
||||
profileImageHash: profile.customerImage,
|
||||
loginTag: loginTag,
|
||||
profileId: profile.profileId,
|
||||
internalId: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func makeCards(from response: [String: Any], profile: MibProfile, loginTag: String) -> [BankAccount] {
|
||||
let keys = ["cards", "cardList", "cardDetails", "debitCards", "creditCards", "accountCards"]
|
||||
let cardArrays = keys.compactMap { response[$0] as? [[String: Any]] }
|
||||
return cardArrays.flatMap { $0 }.compactMap { makeCardAccount(from: $0, profile: profile, loginTag: loginTag) }
|
||||
}
|
||||
|
||||
private func makeCardAccount(from card: [String: Any], profile: MibProfile, loginTag: String) -> BankAccount? {
|
||||
let cardId = firstString(card, keys: ["cardId", "id", "cardNo", "cardNumber", "maskedCardNumber"])
|
||||
let maskedNumber = firstString(card, keys: ["maskedCardNumber", "maskedCardNo", "cardNumber", "cardNo", "cardId"])
|
||||
guard !cardId.isEmpty || !maskedNumber.isEmpty else { return nil }
|
||||
|
||||
let productCode = firstString(card, keys: ["cardType", "productCode", "cardProductCode"])
|
||||
let typeName = firstString(card, keys: ["cardTypeDesc", "cardDescription", "productName"])
|
||||
let holder = firstString(card, keys: ["cardHolderName", "holderName", "customerName"])
|
||||
let status = firstString(card, keys: ["cardStatus", "statusDesc", "status"])
|
||||
let identifier = cardId.isEmpty ? maskedNumber : cardId
|
||||
|
||||
return BankAccount(
|
||||
id: "MIB_CARD_\(identifier)_\(loginTag)",
|
||||
bank: "MIB",
|
||||
profileName: holder.isEmpty ? profile.name : holder,
|
||||
profileType: "MIB_CARD",
|
||||
productCode: productCode,
|
||||
accountNumber: maskedNumber.isEmpty ? identifier : maskedNumber,
|
||||
accountBriefName: holder,
|
||||
currencyName: firstString(card, keys: ["currencyName", "currency"]).isEmpty ? "MVR" : firstString(card, keys: ["currencyName", "currency"]),
|
||||
accountTypeName: typeName.isEmpty ? "MIB Card" : typeName,
|
||||
availableBalance: doubleValue(card, keys: ["availableBalance", "availableLimit", "balance"]),
|
||||
currentBalance: doubleValue(card, keys: ["currentBalance", "balance"]),
|
||||
blockedAmount: 0,
|
||||
mvrBalance: nil,
|
||||
statusDesc: status.isEmpty ? "Active" : status,
|
||||
profileImageHash: profile.customerImage,
|
||||
loginTag: loginTag,
|
||||
profileId: profile.profileId,
|
||||
internalId: cardId.isEmpty ? nil : cardId
|
||||
)
|
||||
}
|
||||
|
||||
private func firstString(_ dictionary: [String: Any], keys: [String]) -> String {
|
||||
for key in keys {
|
||||
if let value = dictionary[key] as? String, !value.isEmpty { return value }
|
||||
if let value = dictionary[key] as? NSNumber { return value.stringValue }
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private func doubleValue(_ dictionary: [String: Any], keys: [String]) -> Double {
|
||||
for key in keys {
|
||||
if let value = dictionary[key] as? Double { return value }
|
||||
if let value = dictionary[key] as? NSNumber { return value.doubleValue }
|
||||
if let value = dictionary[key] as? String, let parsed = Double(value.replacingOccurrences(of: ",", with: "")) { return parsed }
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private func getOrCreateAppId(for username: String) -> String {
|
||||
let key = "mib_\(username)_enc_app_id"
|
||||
if let existing = credentialString(key) { return existing }
|
||||
let newId = MibNonce.generateAppId()
|
||||
try? CredentialStore.shared.save(newId, forKey: key)
|
||||
return newId
|
||||
}
|
||||
|
||||
private func credentialString(_ key: String) -> String? {
|
||||
CredentialStore.shared.load(forKey: key)
|
||||
}
|
||||
|
||||
private func hiddenProfileIds(for username: String) -> Set<String> {
|
||||
let key = "mib_\(username)_hidden_profiles"
|
||||
guard let str = CredentialStore.shared.load(forKey: key) else { return [] }
|
||||
return Set(str.split(separator: ",").map(String.init))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
|
||||
struct MibSession: Codable {
|
||||
let appId: String
|
||||
let xxid: String
|
||||
let nonceGenerator: String
|
||||
let sessionKey: String
|
||||
}
|
||||
|
||||
struct MibProfile: Codable, Identifiable {
|
||||
let profileId: String
|
||||
let customerProfileId: String
|
||||
let annexId: String
|
||||
let customerId: String
|
||||
let name: String
|
||||
let cifType: String
|
||||
let profileType: String
|
||||
let color: String
|
||||
let customerImage: String?
|
||||
|
||||
var id: String { profileId }
|
||||
}
|
||||
|
||||
struct MibCard: Codable, Identifiable {
|
||||
let cardId: String
|
||||
let maskedCardNumber: String
|
||||
let cardStatus: String
|
||||
let cardType: String
|
||||
let cardTypeDesc: String
|
||||
let customerId: String
|
||||
let phoneNumber: String
|
||||
let cardHolderName: String
|
||||
let loginTag: String
|
||||
|
||||
var id: String { cardId }
|
||||
}
|
||||
|
||||
struct MibFinanceDeal: Codable, Identifiable {
|
||||
let dealNo: String
|
||||
let productDesc: String
|
||||
let dealStatus: String
|
||||
let statusDesc: String
|
||||
let dealAmount: Double
|
||||
let paidAmount: Double
|
||||
let outstandingAmount: Double
|
||||
let dealDate: String
|
||||
let overdueAmount: Double
|
||||
let installmentAmount: Double
|
||||
let noOfInstallments: Int
|
||||
let lastPaidDate: String
|
||||
let lastPayAmount: Double
|
||||
let currency: String
|
||||
|
||||
var id: String { dealNo }
|
||||
}
|
||||
|
||||
struct MibTransferResult {
|
||||
let success: Bool
|
||||
let trxId: String
|
||||
let date: String
|
||||
let errorMessage: String
|
||||
}
|
||||
|
||||
enum MibError: LocalizedError {
|
||||
case networkError(String)
|
||||
case serverError(String)
|
||||
case sessionExpired
|
||||
case invalidCredentials
|
||||
case decryptionFailed
|
||||
case encryptionFailed
|
||||
case invalidResponse
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .networkError(let msg): return "Network error: \(msg)"
|
||||
case .serverError(let msg): return "Server error: \(msg)"
|
||||
case .sessionExpired: return "Session expired. Please log in again."
|
||||
case .invalidCredentials: return "Invalid credentials."
|
||||
case .decryptionFailed: return "Failed to decrypt response."
|
||||
case .encryptionFailed: return "Failed to encrypt request."
|
||||
case .invalidResponse: return "Unexpected server response."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import Foundation
|
||||
|
||||
enum MibNonce {
|
||||
/**
|
||||
* Generates the nonce string from the nonceGenerator token returned by DH key exchange.
|
||||
*
|
||||
* Phase 1: for each dash-separated group, take the first token's digits * random(1-99),
|
||||
* pad to 5 digits, compute digitSum and lastTwo.
|
||||
* Phase 2: for each group tokens 1-7, apply the operation letter with carry chain.
|
||||
* Operations: M=(carry%num)+ds+cumSum, A=carry+num+ds+cumSum,
|
||||
* S=carry²+num+ds+cumSum, X=carry*num+ds+cumSum, C=carry³+num+ds+cumSum
|
||||
*/
|
||||
static func generate(_ nonceGenerator: String) -> String {
|
||||
let groups = nonceGenerator.split(separator: "-", omittingEmptySubsequences: false).map(String.init)
|
||||
|
||||
var paddedList: [String] = []
|
||||
var lastTwoList: [Int] = []
|
||||
var digitSumList: [Int] = []
|
||||
var cumSum = 0
|
||||
|
||||
// Phase 1
|
||||
for group in groups {
|
||||
let tokens = group.trimmingCharacters(in: .whitespaces).components(separatedBy: " ")
|
||||
let nStr = tokens[0].filter { $0.isNumber }
|
||||
let n = Int(nStr) ?? 1
|
||||
let r = Int.random(in: 1...99)
|
||||
let product = n * r
|
||||
let padded = String(format: "%05d", product)
|
||||
let ds = padded.compactMap { $0.wholeNumberValue }.reduce(0, +)
|
||||
let lt = Int(padded.suffix(2)) ?? 0
|
||||
paddedList.append(padded)
|
||||
lastTwoList.append(lt)
|
||||
digitSumList.append(ds)
|
||||
cumSum += ds
|
||||
}
|
||||
|
||||
// Phase 2
|
||||
var resultGroups: [String] = []
|
||||
for (i, group) in groups.enumerated() {
|
||||
let tokens = group.trimmingCharacters(in: .whitespaces).components(separatedBy: " ")
|
||||
var carry = lastTwoList[i]
|
||||
let ds = digitSumList[i]
|
||||
var nonceDigits: [Int] = []
|
||||
|
||||
for j in 1...7 {
|
||||
guard j < tokens.count else { nonceDigits.append(0); continue }
|
||||
let token = tokens[j]
|
||||
let op = token.filter { $0.isLetter }
|
||||
let num = Int(token.filter { $0.isNumber }) ?? 1
|
||||
let value: Int64
|
||||
switch op {
|
||||
case "M": value = Int64(carry % num) + Int64(ds) + Int64(cumSum)
|
||||
case "A": value = Int64(carry) + Int64(num) + Int64(ds) + Int64(cumSum)
|
||||
case "S": value = Int64(carry) * Int64(carry) + Int64(num) + Int64(ds) + Int64(cumSum)
|
||||
case "X": value = Int64(carry) * Int64(num) + Int64(ds) + Int64(cumSum)
|
||||
case "C": value = Int64(carry) * Int64(carry) * Int64(carry) + Int64(num) + Int64(ds) + Int64(cumSum)
|
||||
default: value = 0
|
||||
}
|
||||
let digit = Int(String(abs(value)).suffix(2)) ?? 0
|
||||
nonceDigits.append(digit)
|
||||
carry = digit
|
||||
}
|
||||
|
||||
let digitStr = nonceDigits.map { String(format: "%02d", $0) }.joined(separator: " ")
|
||||
resultGroups.append("\(paddedList[i]) \(digitStr)")
|
||||
}
|
||||
|
||||
return resultGroups.joined(separator: "-")
|
||||
}
|
||||
|
||||
// Random long in [1_000_000, 16_000_000)
|
||||
static func randomSodium() -> String {
|
||||
String(Int64.random(in: 1_000_000..<16_000_000))
|
||||
}
|
||||
|
||||
// Random long in [0, 2^40)
|
||||
static func randomXxid() -> String {
|
||||
String(Int64.random(in: 0..<(1 << 40)))
|
||||
}
|
||||
|
||||
// Generates persistent App ID in IOS format: "IOS17.2-{15 alphanumeric chars}"
|
||||
static func generateAppId() -> String {
|
||||
let chars = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
|
||||
let random = (0..<15).map { _ in chars.randomElement()! }
|
||||
return "IOS17.2-\(String(random))"
|
||||
}
|
||||
|
||||
// Random alphanumeric string of given length
|
||||
static func randomAlpha(_ length: Int) -> String {
|
||||
let chars = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
|
||||
return String((0..<length).map { _ in chars.randomElement()! })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import Foundation
|
||||
|
||||
struct MibAccountLookupResult: Sendable {
|
||||
let accountNumber: String
|
||||
let accountName: String
|
||||
let bankName: String
|
||||
let bankCode: String
|
||||
let aliasId: String?
|
||||
let network: TransferNetwork
|
||||
}
|
||||
|
||||
// Handles MIB beneficiary lookups and transfer submission via the WebView API domain.
|
||||
// Session identity requires the Cookie header (mbmodel, xxid, IBSID, mbnonce) on every request.
|
||||
actor MibTransferClient {
|
||||
|
||||
private let wvBase = "https://faisamobilex-wv.mib.com.mv"
|
||||
private let session: MibSession
|
||||
|
||||
private let urlSession: URLSession = {
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.timeoutIntervalForRequest = 30
|
||||
cfg.timeoutIntervalForResource = 60
|
||||
cfg.httpCookieAcceptPolicy = .never
|
||||
cfg.httpShouldSetCookies = false
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
|
||||
init(session: MibSession) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
// MARK: - Account lookup
|
||||
|
||||
func lookupAccount(_ query: String) async throws -> MibAccountLookupResult {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespaces)
|
||||
// 17-digit starting with 9 → MIB internal
|
||||
if trimmed.count == 17 && trimmed.hasPrefix("9") {
|
||||
return try await lookupMibInternal(trimmed)
|
||||
}
|
||||
// 13-digit starting with 7 → IPS/local (BML etc.)
|
||||
if trimmed.count == 13 && trimmed.hasPrefix("7") {
|
||||
return try await lookupIPS(trimmed)
|
||||
}
|
||||
// Everything else → Favara alias
|
||||
return try await lookupFavara(trimmed)
|
||||
}
|
||||
|
||||
private func lookupMibInternal(_ number: String) async throws -> MibAccountLookupResult {
|
||||
let resp = try await postFormJSON(
|
||||
path: "ajaxBeneficiary/getAccountName",
|
||||
fields: [("accountNo", number)]
|
||||
)
|
||||
guard resp["success"] as? Bool == true else {
|
||||
throw MibError.serverError(resp["reasonText"] as? String ?? "Account not found")
|
||||
}
|
||||
// Server returns accountName at root level or inside data dict/array
|
||||
let name: String
|
||||
if let n = resp["accountName"] as? String, !n.isEmpty {
|
||||
name = n
|
||||
} else if let dataDict = resp["data"] as? [String: Any],
|
||||
let n = dataDict["accountName"] as? String {
|
||||
name = n
|
||||
} else if let dataArr = resp["data"] as? [[String: Any]],
|
||||
let n = dataArr.first?["accountName"] as? String {
|
||||
name = n
|
||||
} else {
|
||||
throw MibError.serverError("Account not found")
|
||||
}
|
||||
return MibAccountLookupResult(
|
||||
accountNumber: number,
|
||||
accountName: name,
|
||||
bankName: "Maldives Islamic Bank",
|
||||
bankCode: "2",
|
||||
aliasId: nil,
|
||||
network: .mibInternal
|
||||
)
|
||||
}
|
||||
|
||||
private func lookupIPS(_ number: String) async throws -> MibAccountLookupResult {
|
||||
// Kotlin field name is benefAccount, not accountNo
|
||||
let resp = try await postFormJSON(
|
||||
path: "AjaxAlias/getIPSAccount",
|
||||
fields: [("benefAccount", number)]
|
||||
)
|
||||
guard resp["success"] as? Bool == true else {
|
||||
throw MibError.serverError(resp["reasonText"] as? String ?? "Account not found")
|
||||
}
|
||||
let name = resp["accountName"] as? String
|
||||
?? (resp["data"] as? [[String: Any]])?.first?["accountName"] as? String
|
||||
?? "Unknown"
|
||||
let bankName = resp["bankName"] as? String ?? "Local Bank"
|
||||
return MibAccountLookupResult(
|
||||
accountNumber: number,
|
||||
accountName: name,
|
||||
bankName: bankName,
|
||||
bankCode: "3",
|
||||
aliasId: nil,
|
||||
network: .local
|
||||
)
|
||||
}
|
||||
|
||||
private func lookupFavara(_ alias: String) async throws -> MibAccountLookupResult {
|
||||
// Kotlin field name is aliasName, not alias
|
||||
let resp = try await postFormJSON(
|
||||
path: "AjaxAlias/getAlias",
|
||||
fields: [("aliasName", alias)]
|
||||
)
|
||||
guard resp["success"] as? Bool == true else {
|
||||
throw MibError.serverError(resp["reasonText"] as? String ?? "Alias not found")
|
||||
}
|
||||
// Response: { data: { BfyNm, CdtrAcct: { Acct, FinInstnId } } }
|
||||
guard let dataDict = resp["data"] as? [String: Any] else {
|
||||
throw MibError.serverError("Alias not found")
|
||||
}
|
||||
let name = dataDict["BfyNm"] as? String ?? "Unknown"
|
||||
let cdtrAcct = dataDict["CdtrAcct"] as? [String: Any]
|
||||
let accountNum = cdtrAcct?["Acct"] as? String ?? alias
|
||||
let bic = cdtrAcct?["FinInstnId"] as? String ?? ""
|
||||
// Determine network based on account number format
|
||||
let network: TransferNetwork = accountNum.count == 17 && accountNum.hasPrefix("9")
|
||||
? .mibInternal : .local
|
||||
let bankCode = network == .mibInternal ? "2" : "3"
|
||||
return MibAccountLookupResult(
|
||||
accountNumber: accountNum,
|
||||
accountName: name,
|
||||
bankName: bic.isEmpty ? "Maldives Islamic Bank" : bic,
|
||||
bankCode: bankCode,
|
||||
aliasId: alias,
|
||||
network: network
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Transfer
|
||||
|
||||
func executeTransfer(
|
||||
from fromAccount: BankAccount,
|
||||
to lookup: MibAccountLookupResult,
|
||||
amount: Double,
|
||||
currency: String,
|
||||
purpose: String,
|
||||
otp: String // caller generates TOTP before entering actor context
|
||||
) async throws -> MibTransferResult {
|
||||
let endpoint = lookup.network == .mibInternal
|
||||
? "ajaxTransfer/transferInternal"
|
||||
: "ajaxTransfer/transferLocal"
|
||||
|
||||
// Kotlin maps currency name → ISO 4217 numeric code
|
||||
let currencyCode: String
|
||||
switch currency.uppercased() {
|
||||
case "MVR": currencyCode = "462"
|
||||
case "USD": currencyCode = "840"
|
||||
default: currencyCode = currency
|
||||
}
|
||||
|
||||
let fields: [(String, String)] = [
|
||||
("benefName", lookup.accountName.isEmpty ? "Recipient" : lookup.accountName),
|
||||
("benefNo", "0"), // Kotlin always sends "0"
|
||||
("fromAccountNo", fromAccount.accountNumber),
|
||||
("benefAccountNo", lookup.accountNumber),
|
||||
("transferCy", currencyCode),
|
||||
("benefCurrencyCode", currencyCode),
|
||||
("amount", String(format: "%.2f", amount)),
|
||||
("bankNo", lookup.bankCode), // "2" MIB internal, "3" local
|
||||
("purpose", purpose.isEmpty ? "-" : purpose),
|
||||
("otp", otp),
|
||||
("otpType", "3")
|
||||
]
|
||||
|
||||
let resp = try await postFormJSON(path: endpoint, fields: fields)
|
||||
guard resp["success"] as? Bool == true else {
|
||||
throw MibError.serverError(resp["reasonText"] as? String ?? "Transfer failed")
|
||||
}
|
||||
// Response: { success: true, data: [{ trxId, date }] }
|
||||
let dataArr = resp["data"] as? [[String: Any]]
|
||||
let trxId = dataArr?.first?["trxId"] as? String
|
||||
?? resp["trxId"] as? String ?? ""
|
||||
let date = dataArr?.first?["date"] as? String
|
||||
?? resp["transactionDate"] as? String ?? ""
|
||||
return MibTransferResult(success: true, trxId: trxId, date: date, errorMessage: "")
|
||||
}
|
||||
|
||||
// MARK: - HTTP
|
||||
|
||||
private var cookieHeader: String {
|
||||
"mbmodel=IOS-1.0; xxid=\(session.xxid); IBSID=\(session.xxid); mbnonce=\(session.nonceGenerator); time-tracker=597"
|
||||
}
|
||||
|
||||
private func postFormJSON(path: String, fields: [(String, String)]) async throws -> [String: Any] {
|
||||
guard let url = URL(string: "\(wvBase)/\(path)") else {
|
||||
throw MibError.networkError("Bad URL: \(path)")
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
|
||||
forHTTPHeaderField: "User-Agent"
|
||||
)
|
||||
request.setValue("*/*", forHTTPHeaderField: "Accept")
|
||||
request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
|
||||
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
||||
request.setValue(wvBase, forHTTPHeaderField: "Origin")
|
||||
request.setValue("\(wvBase)/transfer/quick", forHTTPHeaderField: "Referer")
|
||||
|
||||
var allowed = CharacterSet.alphanumerics
|
||||
allowed.insert(charactersIn: "-._~")
|
||||
let body = fields.map { k, v in
|
||||
"\(k.addingPercentEncoding(withAllowedCharacters: allowed) ?? k)=\(v.addingPercentEncoding(withAllowedCharacters: allowed) ?? v)"
|
||||
}.joined(separator: "&")
|
||||
request.httpBody = Data(body.utf8)
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if code == 419 { throw MibError.sessionExpired }
|
||||
if code >= 500 { throw MibError.networkError("HTTP \(code)") }
|
||||
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw MibError.invalidResponse
|
||||
}
|
||||
return obj
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Unified bank data models
|
||||
// These aggregate MIB, BML, and Fahipay responses into a common schema.
|
||||
// All types are Codable for cache serialisation and Hashable for use in SwiftUI ForEach.
|
||||
|
||||
struct BankAccount: Identifiable, Codable, Hashable {
|
||||
let id: String // Synthetic: "\(bank)_\(accountNumber)_\(loginTag)"
|
||||
let bank: String // "MIB" | "BML" | "FAHIPAY"
|
||||
let profileName: String
|
||||
let profileType: String
|
||||
let productCode: String
|
||||
let accountNumber: String
|
||||
let accountBriefName: String
|
||||
let currencyName: String // "MVR" | "USD" | ...
|
||||
let accountTypeName: String
|
||||
let availableBalance: Double
|
||||
let currentBalance: Double
|
||||
let blockedAmount: Double
|
||||
let mvrBalance: Double? // Converted balance when currency != MVR
|
||||
let statusDesc: String
|
||||
let profileImageHash: String?
|
||||
let loginTag: String // loginId that owns this account
|
||||
let profileId: String?
|
||||
let internalId: String?
|
||||
|
||||
var isActive: Bool {
|
||||
statusDesc.lowercased().contains("active")
|
||||
}
|
||||
|
||||
var formattedAvailableBalance: String {
|
||||
"\(currencyName) \(Self.balanceFormatter.string(from: NSNumber(value: availableBalance)) ?? "0.00")"
|
||||
}
|
||||
|
||||
private static let balanceFormatter: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .decimal
|
||||
f.minimumFractionDigits = 2
|
||||
f.maximumFractionDigits = 2
|
||||
return f
|
||||
}()
|
||||
}
|
||||
|
||||
struct BankContact: Identifiable, Codable, Hashable {
|
||||
let id: String // Unique composite across banks
|
||||
let benefNo: String
|
||||
let benefName: String
|
||||
let benefNickName: String?
|
||||
let benefAccount: String
|
||||
let benefType: String // "MIB" | "LOCAL" | "SWIFT" | "BML" | "FAHIPAY"
|
||||
let bankColor: String?
|
||||
let benefBankName: String?
|
||||
let bankCode: String?
|
||||
let benefStatus: String
|
||||
let transferCyDesc: String?
|
||||
let customerImgHash: String?
|
||||
let benefCategoryId: String?
|
||||
let profileId: String?
|
||||
let source: String // "MIB" | "BML" | "FAHIPAY"
|
||||
|
||||
var displayName: String {
|
||||
if let nick = benefNickName, !nick.isEmpty { return nick }
|
||||
return benefName
|
||||
}
|
||||
}
|
||||
|
||||
struct BankTransaction: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
let date: Date
|
||||
let description: String
|
||||
let amount: Double // Positive = credit, negative = debit
|
||||
let currency: String
|
||||
let counterpartyName: String?
|
||||
let reference: String?
|
||||
let accountNumber: String
|
||||
let accountDisplayName: String
|
||||
let source: String // "MIB" | "BML" | "BML_CARD" | "FAHIPAY"
|
||||
let iconUrl: String?
|
||||
|
||||
var isCredit: Bool { amount >= 0 }
|
||||
|
||||
var formattedAmount: String {
|
||||
let sign = isCredit ? "+" : "-"
|
||||
let abs = Self.amountFormatter.string(from: NSNumber(value: Swift.abs(amount))) ?? "0.00"
|
||||
return "\(sign) \(currency) \(abs)"
|
||||
}
|
||||
|
||||
private static let amountFormatter: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .decimal
|
||||
f.minimumFractionDigits = 2
|
||||
f.maximumFractionDigits = 2
|
||||
return f
|
||||
}()
|
||||
}
|
||||
|
||||
struct BankContactCategory: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
let categoryName: String
|
||||
let numBenef: Int
|
||||
let source: String // "MIB" | "FAHIPAY"
|
||||
}
|
||||
|
||||
struct BankNotification: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
let title: String
|
||||
let body: String
|
||||
let date: Date
|
||||
var isRead: Bool
|
||||
let source: String
|
||||
}
|
||||
|
||||
// MARK: - Transfer
|
||||
|
||||
struct TransferResult: Codable {
|
||||
let success: Bool
|
||||
let transactionId: String?
|
||||
let reference: String?
|
||||
let date: Date?
|
||||
let errorMessage: String?
|
||||
}
|
||||
|
||||
struct TransferReceiptData: Sendable, Equatable {
|
||||
let fromAccountNumber: String
|
||||
let fromBankName: String
|
||||
let toAccountNumber: String
|
||||
let toAccountName: String
|
||||
let amount: Double
|
||||
let currency: String
|
||||
let reference: String
|
||||
let date: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct RecentPick: Identifiable, Codable, Hashable {
|
||||
let id: String
|
||||
let benefName: String
|
||||
let benefAccount: String
|
||||
let transferNetwork: String // TransferNetwork.rawValue
|
||||
let bankName: String?
|
||||
let lastUsed: Date
|
||||
}
|
||||
|
||||
// MARK: - Connectivity
|
||||
|
||||
enum ConnectivityError: String, CaseIterable, Codable {
|
||||
case noInternet = "NO_INTERNET"
|
||||
case mib = "MIB"
|
||||
case bml = "BML"
|
||||
case fahipay = "FAHIPAY"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
// Mirrors Android's TransferNetwork enum exactly (same raw values).
|
||||
enum TransferNetwork: String, Codable, CaseIterable, Equatable {
|
||||
case mibInternal = "MIB"
|
||||
case local = "LOCAL"
|
||||
case swift = "SWIFT"
|
||||
case bml = "BML"
|
||||
case fahipay = "FAHIPAY"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .mibInternal: return "MIB Internal"
|
||||
case .local: return "Local (IPS)"
|
||||
case .swift: return "International (SWIFT)"
|
||||
case .bml: return "BML"
|
||||
case .fahipay: return "Fahipay"
|
||||
}
|
||||
}
|
||||
|
||||
var bankFullName: String {
|
||||
switch self {
|
||||
case .mibInternal: return "Maldives Islamic Bank"
|
||||
case .local: return "Local Bank Transfer"
|
||||
case .swift: return "International Wire Transfer"
|
||||
case .bml: return "Bank of Maldives"
|
||||
case .fahipay: return "Fahipay"
|
||||
}
|
||||
}
|
||||
|
||||
var isInternational: Bool { self == .swift }
|
||||
var requiresSwiftCode: Bool { self == .swift }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "amex_credit_gold.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 119 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "amex_credit_green.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 123 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "amex_debit_gold.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 110 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "amex_debit_green.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "amex_platinum.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 128 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "defaultcard.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "master.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 191 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "master_business_debit.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 73 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "master_gold.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 122 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "master_islamic.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 133 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "master_masveriyaa.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 296 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "master_odiveriyaa.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 279 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "master_passport.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 135 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "master_platinum.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "master_prepaid.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "master_prepaid_business.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 154 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "master_prepaid_travel.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "master_world.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_corporate.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_credit.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 133 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_debit.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_debit_generic.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_debit_islamic.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 260 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_debit_platinum.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 112 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_gold.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 213 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_infinite.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_platinum.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_student_black.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_student_blue.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 116 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "fais_wear_smart_sticker.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 230 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "faisa_card.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "faisa_wear_ring_black.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 185 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "faisa_wear_ring_floral.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 267 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_bingaa.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 196 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_bingaa_mvr.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 196 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_bingaa_usd.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 196 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_black_platinum.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 161 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_blue_everyday.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 192 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"images": [{"filename": "visa_business.png", "idiom": "universal", "scale": "1x"}],
|
||||
"info": {"author": "xcode", "version": 1}
|
||||
}
|
||||
|
After Width: | Height: | Size: 36 KiB |
@@ -1,61 +1,27 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// Thijooree iOS
|
||||
//
|
||||
// Created by Mohamed Azim on 6/6/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query private var items: [Item]
|
||||
// Root navigation controller. Switches between top-level app states driven by AppViewModel.route.
|
||||
struct AppRouter: View {
|
||||
@Environment(AppViewModel.self) private var appViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List {
|
||||
ForEach(items) { item in
|
||||
NavigationLink {
|
||||
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
|
||||
} label: {
|
||||
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteItems)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
EditButton()
|
||||
}
|
||||
ToolbarItem {
|
||||
Button(action: addItem) {
|
||||
Label("Add Item", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
Text("Select an item")
|
||||
}
|
||||
}
|
||||
|
||||
private func addItem() {
|
||||
withAnimation {
|
||||
let newItem = Item(timestamp: Date())
|
||||
modelContext.insert(newItem)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteItems(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
for index in offsets {
|
||||
modelContext.delete(items[index])
|
||||
Group {
|
||||
switch appViewModel.route {
|
||||
case .onboarding:
|
||||
OnboardingView()
|
||||
case .login:
|
||||
BankSelectionView()
|
||||
case .lock:
|
||||
LockScreenView { appViewModel.unlock() }
|
||||
case .home:
|
||||
HomeView()
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: appViewModel.route)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.modelContainer(for: Item.self, inMemory: true)
|
||||
AppRouter()
|
||||
.environment(AppViewModel())
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
//
|
||||
// Item.swift
|
||||
// Thijooree iOS
|
||||
//
|
||||
// Created by Mohamed Azim on 6/6/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class Item {
|
||||
var timestamp: Date
|
||||
|
||||
init(timestamp: Date) {
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import CommonCrypto
|
||||
import Foundation
|
||||
|
||||
enum PinHash {
|
||||
private static let iterations: UInt32 = 100_000
|
||||
private static let keyLength = 32 // 256 bits
|
||||
|
||||
static func generateSalt() -> Data {
|
||||
var salt = Data(count: 16)
|
||||
_ = salt.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
return salt
|
||||
}
|
||||
|
||||
static func hash(_ input: String, salt: Data) -> String {
|
||||
var derivedKey = Data(count: keyLength)
|
||||
let inputData = Data(input.utf8)
|
||||
|
||||
_ = derivedKey.withUnsafeMutableBytes { derivedPtr in
|
||||
inputData.withUnsafeBytes { inputPtr in
|
||||
salt.withUnsafeBytes { saltPtr in
|
||||
CCKeyDerivationPBKDF(
|
||||
CCPBKDFAlgorithm(kCCPBKDF2),
|
||||
inputPtr.baseAddress, inputData.count,
|
||||
saltPtr.baseAddress, salt.count,
|
||||
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
|
||||
iterations,
|
||||
derivedPtr.baseAddress, keyLength
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return derivedKey.base64EncodedString()
|
||||
}
|
||||
|
||||
static func verify(_ input: String, against storedHash: String, salt: Data) -> Bool {
|
||||
hash(input, salt: salt) == storedHash
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
enum Totp {
|
||||
// timeStep offsets the counter by N periods — use -1/+1 to handle clock skew at window boundaries.
|
||||
static func generate(_ base32Secret: String, digits: Int = 6, period: Int = 30, timeStep: Int = 0) -> String {
|
||||
let counter = Int64(Date().timeIntervalSince1970) / Int64(period) + Int64(timeStep)
|
||||
guard let keyBytes = base32Decode(base32Secret.uppercased()) else { return String(repeating: "0", count: digits) }
|
||||
let otp = hotp(key: keyBytes, counter: counter, digits: digits)
|
||||
return String(format: "%0\(digits)d", otp)
|
||||
}
|
||||
|
||||
private static func hotp(key: [UInt8], counter: Int64, digits: Int) -> Int {
|
||||
var counterBigEndian = counter.bigEndian
|
||||
let counterData = withUnsafeBytes(of: &counterBigEndian) { Data($0) }
|
||||
|
||||
let hmac = HMAC<Insecure.SHA1>.authenticationCode(
|
||||
for: counterData,
|
||||
using: SymmetricKey(data: Data(key))
|
||||
)
|
||||
let hash = Array(hmac)
|
||||
|
||||
let offset = Int(hash[hash.count - 1] & 0x0F)
|
||||
let truncated = (Int(hash[offset]) & 0x7F) << 24
|
||||
| Int(hash[offset + 1]) << 16
|
||||
| Int(hash[offset + 2]) << 8
|
||||
| Int(hash[offset + 3])
|
||||
|
||||
let modulus = Int(pow(10.0, Double(digits)))
|
||||
return truncated % modulus
|
||||
}
|
||||
|
||||
private static func base32Decode(_ input: String) -> [UInt8]? {
|
||||
let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||
var bits = 0
|
||||
var bitCount = 0
|
||||
var result: [UInt8] = []
|
||||
|
||||
for char in input where char != "=" {
|
||||
guard let idx = alphabet.firstIndex(of: char) else { return nil }
|
||||
let value = alphabet.distance(from: alphabet.startIndex, to: idx)
|
||||
bits = (bits << 5) | value
|
||||
bitCount += 5
|
||||
if bitCount >= 8 {
|
||||
bitCount -= 8
|
||||
result.append(UInt8((bits >> bitCount) & 0xFF))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
// Encrypted account cache backed by UserDefaults (AES-256-GCM via CacheEncryption).
|
||||
// Used by HomeViewModel to display cached data instantly on launch while a fresh
|
||||
// network fetch runs in the background.
|
||||
struct AccountCache {
|
||||
static let shared = AccountCache()
|
||||
private let udKey = "thijooree_accounts_v1"
|
||||
private init() {}
|
||||
|
||||
func save(_ accounts: [BankAccount]) {
|
||||
guard let json = try? JSONEncoder().encode(accounts),
|
||||
let encrypted = try? CacheEncryption.shared.encryptData(json) else { return }
|
||||
UserDefaults.standard.set(encrypted, forKey: udKey)
|
||||
}
|
||||
|
||||
func load() -> [BankAccount] {
|
||||
guard let encrypted = UserDefaults.standard.string(forKey: udKey),
|
||||
let json = try? CacheEncryption.shared.decryptData(encrypted),
|
||||
let accounts = try? JSONDecoder().decode([BankAccount].self, from: json) else { return [] }
|
||||
return accounts
|
||||
}
|
||||
|
||||
func clear() {
|
||||
UserDefaults.standard.removeObject(forKey: udKey)
|
||||
}
|
||||
}
|
||||