Implement bank transfer app flows
@@ -400,6 +400,8 @@
|
|||||||
DEVELOPMENT_TEAM = 437JYGSZYP;
|
DEVELOPMENT_TEAM = 437JYGSZYP;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_NSCameraUsageDescription = "Used to scan PayMV QR codes for transfers";
|
||||||
|
INFOPLIST_KEY_NSFaceIDUsageDescription = "Thijooree uses Face ID to protect access to your banking credentials.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@@ -432,6 +434,8 @@
|
|||||||
DEVELOPMENT_TEAM = 437JYGSZYP;
|
DEVELOPMENT_TEAM = 437JYGSZYP;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_NSCameraUsageDescription = "Used to scan PayMV QR codes for transfers";
|
||||||
|
INFOPLIST_KEY_NSFaceIDUsageDescription = "Thijooree uses Face ID to protect access to your banking credentials.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
|||||||
@@ -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 SwiftUI
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
struct ContentView: View {
|
// Root navigation controller. Switches between top-level app states driven by AppViewModel.route.
|
||||||
@Environment(\.modelContext) private var modelContext
|
struct AppRouter: View {
|
||||||
@Query private var items: [Item]
|
@Environment(AppViewModel.self) private var appViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
Group {
|
||||||
List {
|
switch appViewModel.route {
|
||||||
ForEach(items) { item in
|
case .onboarding:
|
||||||
NavigationLink {
|
OnboardingView()
|
||||||
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
|
case .login:
|
||||||
} label: {
|
BankSelectionView()
|
||||||
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
|
case .lock:
|
||||||
}
|
LockScreenView { appViewModel.unlock() }
|
||||||
}
|
case .home:
|
||||||
.onDelete(perform: deleteItems)
|
HomeView()
|
||||||
}
|
|
||||||
.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])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: appViewModel.route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ContentView()
|
AppRouter()
|
||||||
.modelContainer(for: Item.self, inMemory: true)
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||