Implement bank transfer app flows
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class AppViewModel {
|
||||
|
||||
enum Route: Equatable {
|
||||
case onboarding
|
||||
case login
|
||||
case lock
|
||||
case home
|
||||
}
|
||||
|
||||
private(set) var route: Route
|
||||
|
||||
init() {
|
||||
route = AppViewModel.initialRoute()
|
||||
}
|
||||
|
||||
// MARK: - Route transitions (called by child views)
|
||||
|
||||
func completeOnboarding() {
|
||||
UserDefaults.standard.set(true, forKey: "onboarding_done")
|
||||
route = .login
|
||||
}
|
||||
|
||||
func loginSucceeded() {
|
||||
route = AppViewModel.requiresLock() ? .lock : .home
|
||||
}
|
||||
|
||||
func unlock() {
|
||||
route = .home
|
||||
}
|
||||
|
||||
func lock() {
|
||||
route = .lock
|
||||
}
|
||||
|
||||
func logout() {
|
||||
route = .login
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func initialRoute() -> Route {
|
||||
guard UserDefaults.standard.bool(forKey: "onboarding_done") else {
|
||||
return .onboarding
|
||||
}
|
||||
guard CredentialStore.shared.hasAnyCredentials() else {
|
||||
return .login
|
||||
}
|
||||
return requiresLock() ? .lock : .home
|
||||
}
|
||||
|
||||
private static func requiresLock() -> Bool {
|
||||
let method = UserDefaults.standard.string(forKey: "security_method") ?? ""
|
||||
return !method.isEmpty && method != "none"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class HomeViewModel {
|
||||
|
||||
var accounts: [BankAccount] = []
|
||||
var isRefreshing = false
|
||||
var bankErrors: [String: String] = [:]
|
||||
var hideAmounts = false
|
||||
|
||||
// Contacts
|
||||
var contacts: [BankContact] = []
|
||||
var isLoadingContacts = false
|
||||
var contactsError: String? = nil
|
||||
|
||||
// Foreign transaction limits (BML)
|
||||
var foreignLimits: [(userName: String, limits: [BmlForeignLimit])] = []
|
||||
|
||||
// MIB financing deals
|
||||
var mibFinancing: [MibFinanceDeal] = []
|
||||
|
||||
var totalPendingFinances: Double {
|
||||
mibFinancing.reduce(0) { $0 + $1.outstandingAmount }
|
||||
}
|
||||
|
||||
// Set this to navigate Transfer tab pre-filled with a contact
|
||||
var pendingTransferContact: BankContact? = nil
|
||||
|
||||
private let cacheRefreshInterval: TimeInterval = 5 * 60
|
||||
private var lastDashboardRefresh = Date.distantPast
|
||||
private var lastContactsRefresh = Date.distantPast
|
||||
|
||||
private enum DashboardRefreshResult {
|
||||
case mib(accounts: [BankAccount], financing: [MibFinanceDeal], error: String?)
|
||||
case bml(accounts: [BankAccount], limits: [(userName: String, limits: [BmlForeignLimit])], error: String?)
|
||||
}
|
||||
|
||||
init() {
|
||||
accounts = AccountCache.shared.load()
|
||||
contacts = Self.mergeContacts(ContactsCache.shared.load(), saved: SavedContactsStore.shared.load())
|
||||
if let dashboard = DashboardCache.shared.load() {
|
||||
foreignLimits = dashboard.foreignLimits.map { (userName: $0.userName, limits: $0.limits) }
|
||||
mibFinancing = dashboard.mibFinancing
|
||||
}
|
||||
if !accounts.isEmpty && (!foreignLimits.isEmpty || !mibFinancing.isEmpty) {
|
||||
lastDashboardRefresh = Date()
|
||||
}
|
||||
if !contacts.isEmpty { lastContactsRefresh = Date() }
|
||||
}
|
||||
|
||||
// MARK: - Grouped views
|
||||
|
||||
var mibAccounts: [BankAccount] { casaAccounts.filter { $0.bank == "MIB" } }
|
||||
var bmlAccounts: [BankAccount] { casaAccounts.filter { $0.bank == "BML" } }
|
||||
var fahipayAccounts: [BankAccount] { accounts.filter { $0.bank == "FAHIPAY" } }
|
||||
|
||||
var cardAccounts: [BankAccount] {
|
||||
accounts.filter {
|
||||
$0.profileType == "BML_CREDIT" ||
|
||||
$0.profileType == "BML_DEBIT" ||
|
||||
$0.profileType == "BML_PREPAID" ||
|
||||
$0.profileType == "MIB_CARD"
|
||||
}
|
||||
}
|
||||
|
||||
private var casaAccounts: [BankAccount] {
|
||||
accounts.filter {
|
||||
$0.profileType != "BML_CREDIT" &&
|
||||
$0.profileType != "BML_DEBIT" &&
|
||||
$0.profileType != "BML_PREPAID" &&
|
||||
$0.profileType != "BML_LOAN" &&
|
||||
$0.profileType != "MIB_CARD"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Balance totals (CASA only)
|
||||
|
||||
var totalMvrBalance: Double {
|
||||
casaAccounts.filter { $0.currencyName == "MVR" }.reduce(0) { $0 + $1.availableBalance }
|
||||
}
|
||||
|
||||
var totalUsdBalance: Double {
|
||||
casaAccounts.filter { $0.currencyName == "USD" }.reduce(0) { $0 + $1.availableBalance }
|
||||
}
|
||||
|
||||
// MARK: - Credit / prepaid totals
|
||||
|
||||
var hasCreditAccounts: Bool { !bmlCardAccounts.isEmpty }
|
||||
|
||||
var totalMvrCredit: Double {
|
||||
bmlCardAccounts.filter { $0.currencyName == "MVR" }.reduce(0) { $0 + $1.availableBalance }
|
||||
}
|
||||
|
||||
var totalUsdCredit: Double {
|
||||
bmlCardAccounts.filter { $0.currencyName == "USD" }.reduce(0) { $0 + $1.availableBalance }
|
||||
}
|
||||
|
||||
private var bmlCardAccounts: [BankAccount] {
|
||||
accounts.filter {
|
||||
$0.profileType == "BML_CREDIT" ||
|
||||
$0.profileType == "BML_DEBIT" ||
|
||||
$0.profileType == "BML_PREPAID"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Blocked amounts
|
||||
|
||||
var totalBlockedMvr: Double {
|
||||
accounts.filter { $0.currencyName == "MVR" }.reduce(0) { $0 + $1.blockedAmount }
|
||||
}
|
||||
|
||||
var totalBlockedUsd: Double {
|
||||
accounts.filter { $0.currencyName == "USD" }.reduce(0) { $0 + $1.blockedAmount }
|
||||
}
|
||||
|
||||
// MARK: - Formatting
|
||||
|
||||
func fmt(_ amount: Double, currency: String) -> String {
|
||||
"\(currency) \(Self.balanceFmt.string(from: NSNumber(value: amount)) ?? "0.00")"
|
||||
}
|
||||
|
||||
private static let balanceFmt: NumberFormatter = {
|
||||
let f = NumberFormatter()
|
||||
f.numberStyle = .decimal
|
||||
f.minimumFractionDigits = 2
|
||||
f.maximumFractionDigits = 2
|
||||
f.groupingSeparator = ","
|
||||
f.usesGroupingSeparator = true
|
||||
return f
|
||||
}()
|
||||
|
||||
// MARK: - Refresh
|
||||
// MIB and BML run in parallel; within each bank one login/session is shared for
|
||||
// all sub-requests (accounts + financing for MIB, accounts + limits for BML).
|
||||
|
||||
func refreshIfNeeded() async {
|
||||
guard accounts.isEmpty || Date().timeIntervalSince(lastDashboardRefresh) >= cacheRefreshInterval else { return }
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
guard !isRefreshing else { return }
|
||||
isRefreshing = true
|
||||
bankErrors = [:]
|
||||
var completedRefresh = false
|
||||
defer {
|
||||
if completedRefresh { lastDashboardRefresh = Date() }
|
||||
isRefreshing = false
|
||||
}
|
||||
|
||||
await withTaskGroup(of: DashboardRefreshResult.self) { group in
|
||||
group.addTask {
|
||||
let result = await Self.fetchMibAll()
|
||||
return .mib(accounts: result.accounts, financing: result.financing, error: result.error)
|
||||
}
|
||||
group.addTask {
|
||||
let result = await Self.fetchBmlAll()
|
||||
return .bml(accounts: result.accounts, limits: result.limits, error: result.error)
|
||||
}
|
||||
|
||||
for await result in group {
|
||||
guard !Task.isCancelled else { return }
|
||||
switch result {
|
||||
case .mib(let freshAccounts, let financing, let error):
|
||||
if let error {
|
||||
bankErrors["MIB"] = error
|
||||
} else {
|
||||
replaceAccounts(for: "MIB", with: freshAccounts)
|
||||
mibFinancing = financing
|
||||
DashboardCache.shared.save(foreignLimits: foreignLimits, mibFinancing: mibFinancing)
|
||||
}
|
||||
|
||||
case .bml(let freshAccounts, let limits, let error):
|
||||
if let error {
|
||||
bankErrors["BML"] = error
|
||||
} else {
|
||||
replaceAccounts(for: "BML", with: freshAccounts)
|
||||
foreignLimits = limits
|
||||
DashboardCache.shared.save(foreignLimits: foreignLimits, mibFinancing: mibFinancing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
completedRefresh = !Task.isCancelled
|
||||
}
|
||||
|
||||
private func replaceAccounts(for bank: String, with freshAccounts: [BankAccount]) {
|
||||
guard !freshAccounts.isEmpty || !accounts.contains(where: { $0.bank == bank }) else { return }
|
||||
accounts.removeAll { $0.bank == bank }
|
||||
accounts += freshAccounts
|
||||
AccountCache.shared.save(accounts)
|
||||
}
|
||||
|
||||
// MARK: - MIB: one login per user → accounts + financing with the same session
|
||||
|
||||
private static func fetchMibAll() async -> (accounts: [BankAccount], financing: [MibFinanceDeal], error: String?) {
|
||||
let ids = CredentialStore.shared.loginIds(for: "MIB")
|
||||
var allAccounts: [BankAccount] = []
|
||||
var allFinancing: [MibFinanceDeal] = []
|
||||
var lastErr: String?
|
||||
|
||||
await withTaskGroup(of: (accounts: [BankAccount], financing: [MibFinanceDeal], error: String?).self) { group in
|
||||
for username in ids {
|
||||
group.addTask { await fetchMibUser(username: username) }
|
||||
}
|
||||
for await result in group {
|
||||
allAccounts += result.accounts
|
||||
allFinancing += result.financing
|
||||
if let e = result.error { lastErr = e }
|
||||
}
|
||||
}
|
||||
return (allAccounts, allFinancing, lastErr)
|
||||
}
|
||||
|
||||
private static func fetchMibUser(username: String) async -> (accounts: [BankAccount], financing: [MibFinanceDeal], error: String?) {
|
||||
guard
|
||||
let hash = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibPassword(username)),
|
||||
let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibOtpSeed(username))
|
||||
else {
|
||||
return ([], [], "MIB credentials missing for \(username). Please log in again.")
|
||||
}
|
||||
do {
|
||||
let flow = MibLoginFlow()
|
||||
let accs = try await flow.login(username: username, passwordHash: hash, otpSeed: seed)
|
||||
// Reuse the established session — no second login needed
|
||||
guard let session = await flow.lastSession else { return (accs, [], nil) }
|
||||
let deals = (try? await MibFinancingClient(session: session).fetchFinancing()) ?? []
|
||||
return (accs, deals, nil)
|
||||
} catch {
|
||||
if Self.isCancellation(error) { return ([], [], nil) }
|
||||
return ([], [], error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BML: one session per user → accounts + limits + userinfo in parallel
|
||||
|
||||
private static func fetchBmlAll() async -> (accounts: [BankAccount], limits: [(String, [BmlForeignLimit])], error: String?) {
|
||||
let ids = CredentialStore.shared.loginIds(for: "BML")
|
||||
var allAccounts: [BankAccount] = []
|
||||
var allLimits: [(String, [BmlForeignLimit])] = []
|
||||
var lastErr: String?
|
||||
|
||||
for username in ids {
|
||||
let r = await fetchBmlUserAll(username: username)
|
||||
allAccounts += r.accounts
|
||||
if let lim = r.limits { allLimits.append(lim) }
|
||||
if let e = r.error { lastErr = e }
|
||||
}
|
||||
return (allAccounts, allLimits, lastErr)
|
||||
}
|
||||
|
||||
private static func fetchBmlUserAll(username: String) async -> (accounts: [BankAccount], limits: (String, [BmlForeignLimit])?, error: String?) {
|
||||
let loginTag = "bml_\(username)"
|
||||
let pids = CredentialStore.shared.loadStringArray(forKey: CredentialStore.Keys.bmlProfiles(username))
|
||||
|
||||
// Try cached profiles first
|
||||
if !pids.isEmpty {
|
||||
// Load first valid session to use for limits/userinfo
|
||||
var firstSession: BmlSession? = nil
|
||||
var accountsAll: [BankAccount] = []
|
||||
var accountsError: String? = nil
|
||||
|
||||
for pid in pids {
|
||||
guard let token = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlAccessToken(pid)),
|
||||
!token.isEmpty else {
|
||||
// Token missing — fall back to full login
|
||||
return await bmlFullLoginAll(username: username)
|
||||
}
|
||||
let expStr = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlExpTime(pid)) ?? "0"
|
||||
let expiresAt = Int64(expStr) ?? 0
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let deviceId = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlDeviceId(pid)) ?? ""
|
||||
let refreshTok = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlRefreshToken(pid)) ?? ""
|
||||
var sess = BmlSession(accessToken: token, deviceId: deviceId, refreshToken: refreshTok, expiresAt: expiresAt)
|
||||
|
||||
if expiresAt > 0 && now >= expiresAt - 60_000 {
|
||||
do {
|
||||
let fresh = try await BmlLoginFlow().refreshSession(sess)
|
||||
saveBmlSession(fresh, profileId: pid)
|
||||
sess = fresh
|
||||
} catch {
|
||||
if Self.isCancellation(error) { return ([], nil, nil) }
|
||||
return await bmlFullLoginAll(username: username)
|
||||
}
|
||||
}
|
||||
|
||||
if firstSession == nil { firstSession = sess }
|
||||
|
||||
do {
|
||||
let accs = try await BmlAccountClient(session: sess)
|
||||
.fetchAccounts(loginTag: loginTag, profileId: pid)
|
||||
accountsAll += accs
|
||||
} catch {
|
||||
if Self.isCancellation(error) { return ([], nil, nil) }
|
||||
if case BmlError.sessionExpired = error {
|
||||
return await bmlFullLoginAll(username: username)
|
||||
}
|
||||
accountsError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch limits + userinfo using first session, in parallel with nothing else to wait on
|
||||
var limitsTuple: (String, [BmlForeignLimit])? = nil
|
||||
if let sess = firstSession {
|
||||
limitsTuple = await fetchBmlLimits(session: sess, username: username)
|
||||
}
|
||||
return (accountsAll, limitsTuple, accountsError)
|
||||
}
|
||||
|
||||
// No cached profiles → full login
|
||||
return await bmlFullLoginAll(username: username)
|
||||
}
|
||||
|
||||
private static func bmlFullLoginAll(username: String) async -> (accounts: [BankAccount], limits: (String, [BmlForeignLimit])?, error: String?) {
|
||||
guard
|
||||
let pw = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlPassword(username)),
|
||||
let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlOtpSeed(username))
|
||||
else {
|
||||
return ([], nil, "BML credentials missing for \(username). Please log in again.")
|
||||
}
|
||||
do {
|
||||
let flow = BmlLoginFlow()
|
||||
let loginTag = "bml_\(username)"
|
||||
let profiles = try await flow.login(username: username, password: pw, otpSeed: seed)
|
||||
var allAccounts: [BankAccount] = []
|
||||
var firstSession: BmlSession? = nil
|
||||
for p in profiles where p.profileType != "business" {
|
||||
if case .success(let sess, let accs) = try await flow.activateProfile(p, loginTag: loginTag) {
|
||||
allAccounts += accs
|
||||
saveBmlSession(sess, profileId: p.profileId)
|
||||
if firstSession == nil { firstSession = sess }
|
||||
}
|
||||
}
|
||||
try? CredentialStore.shared.saveStringArray(
|
||||
profiles.map { $0.profileId },
|
||||
forKey: CredentialStore.Keys.bmlProfiles(username)
|
||||
)
|
||||
var limitsTuple: (String, [BmlForeignLimit])? = nil
|
||||
if let sess = firstSession {
|
||||
limitsTuple = await fetchBmlLimits(session: sess, username: username)
|
||||
}
|
||||
return (allAccounts, limitsTuple, nil)
|
||||
} catch {
|
||||
if Self.isCancellation(error) { return ([], nil, nil) }
|
||||
return ([], nil, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch limits + userinfo in parallel using an already-established session
|
||||
private static func fetchBmlLimits(session: BmlSession, username: String) async -> (String, [BmlForeignLimit])? {
|
||||
do {
|
||||
async let limitsTask = BmlForeignLimitsClient().fetchForeignLimits(session: session)
|
||||
async let userInfoTask = BmlAccountClient(session: session).fetchUserInfo()
|
||||
let (limits, userInfo) = try await (limitsTask, userInfoTask)
|
||||
let displayName = userInfo?.fullName.isEmpty == false ? userInfo!.fullName : username
|
||||
return (displayName, limits)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func isCancellation(_ error: Error) -> Bool {
|
||||
if error is CancellationError { return true }
|
||||
if let urlError = error as? URLError, urlError.code == .cancelled { return true }
|
||||
let nsError = error as NSError
|
||||
return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled
|
||||
}
|
||||
|
||||
// MARK: - Contacts
|
||||
|
||||
func fetchContactsIfNeeded() async {
|
||||
guard contacts.isEmpty || Date().timeIntervalSince(lastContactsRefresh) >= cacheRefreshInterval else { return }
|
||||
await fetchContacts()
|
||||
}
|
||||
|
||||
func fetchContacts() async {
|
||||
guard !isLoadingContacts else { return }
|
||||
isLoadingContacts = true
|
||||
contactsError = nil
|
||||
var completedFetch = false
|
||||
defer {
|
||||
if completedFetch { lastContactsRefresh = Date() }
|
||||
isLoadingContacts = false
|
||||
}
|
||||
|
||||
// MIB and BML contacts fetched in parallel
|
||||
async let mibResult = Self.fetchMibContacts()
|
||||
async let bmlResult = Self.fetchBmlContacts()
|
||||
let (mibC, bmlC) = await (mibResult, bmlResult)
|
||||
|
||||
let fetched = mibC.contacts + bmlC.contacts
|
||||
if !fetched.isEmpty || contacts.isEmpty {
|
||||
contacts = Self.mergeContacts(fetched, saved: SavedContactsStore.shared.load())
|
||||
ContactsCache.shared.save(contacts)
|
||||
}
|
||||
if let e = mibC.error { contactsError = e }
|
||||
if let e = bmlC.error { contactsError = (contactsError.map { $0 + "; " } ?? "") + e }
|
||||
completedFetch = true
|
||||
}
|
||||
|
||||
private static func mergeContacts(_ base: [BankContact], saved: [BankContact]) -> [BankContact] {
|
||||
var merged = base
|
||||
for contact in saved where !merged.contains(where: { $0.benefAccount == contact.benefAccount && $0.source == contact.source }) {
|
||||
merged.append(contact)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
private static func fetchMibContacts() async -> (contacts: [BankContact], error: String?) {
|
||||
let ids = CredentialStore.shared.loginIds(for: "MIB")
|
||||
var all: [BankContact] = []
|
||||
var lastErr: String?
|
||||
// Each MIB user runs in parallel; credentials captured before entering the task
|
||||
await withTaskGroup(of: (contacts: [BankContact], error: String?).self) { group in
|
||||
for username in ids {
|
||||
let hash = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibPassword(username))
|
||||
let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibOtpSeed(username))
|
||||
let loginTag = "mib_\(username)"
|
||||
guard let hash, let seed else { continue }
|
||||
group.addTask {
|
||||
do {
|
||||
let flow = MibLoginFlow()
|
||||
_ = try await flow.login(username: username, passwordHash: hash, otpSeed: seed)
|
||||
guard let session = await flow.lastSession else { return ([], nil) }
|
||||
let contacts = try await MibContactsClient(session: session).fetchContacts(loginTag: loginTag)
|
||||
return (contacts, nil)
|
||||
} catch {
|
||||
if Self.isCancellation(error) { return ([], nil) }
|
||||
return ([], error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
for await result in group {
|
||||
all += result.contacts
|
||||
if let e = result.error { lastErr = e }
|
||||
}
|
||||
}
|
||||
return (all, lastErr)
|
||||
}
|
||||
|
||||
private static func fetchBmlContacts() async -> (contacts: [BankContact], error: String?) {
|
||||
let ids = CredentialStore.shared.loginIds(for: "BML")
|
||||
var all: [BankContact] = []
|
||||
var lastErr: String?
|
||||
for username in ids {
|
||||
let loginTag = "bml_\(username)"
|
||||
let pids = CredentialStore.shared.loadStringArray(forKey: CredentialStore.Keys.bmlProfiles(username))
|
||||
guard let pid = pids.first,
|
||||
let token = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlAccessToken(pid)),
|
||||
!token.isEmpty else { continue }
|
||||
let deviceId = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlDeviceId(pid)) ?? ""
|
||||
let refreshTok = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlRefreshToken(pid)) ?? ""
|
||||
let expiresAt = Int64(CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlExpTime(pid)) ?? "0") ?? 0
|
||||
let sess = BmlSession(accessToken: token, deviceId: deviceId, refreshToken: refreshTok, expiresAt: expiresAt)
|
||||
do {
|
||||
let contacts = try await BmlContactsClient(bmlSession: sess).fetchContacts(loginTag: loginTag)
|
||||
all += contacts
|
||||
} catch {
|
||||
if Self.isCancellation(error) { continue }
|
||||
lastErr = error.localizedDescription
|
||||
}
|
||||
}
|
||||
return (all, lastErr)
|
||||
}
|
||||
|
||||
func saveContact(_ contact: BankContact) {
|
||||
SavedContactsStore.shared.save(contact)
|
||||
contacts = Self.mergeContacts(contacts, saved: SavedContactsStore.shared.load())
|
||||
ContactsCache.shared.save(contacts)
|
||||
}
|
||||
|
||||
// MARK: - Grouped contacts
|
||||
|
||||
var mibContacts: [BankContact] { contacts.filter { $0.source == "MIB" } }
|
||||
var bmlContacts: [BankContact] { contacts.filter { $0.source == "BML" } }
|
||||
var fahipayContacts: [BankContact] { contacts.filter { $0.source == "FAHIPAY" } }
|
||||
|
||||
func contacts(matching query: String) -> [BankContact] {
|
||||
guard !query.isEmpty else { return contacts }
|
||||
let q = query.lowercased()
|
||||
return contacts.filter {
|
||||
$0.displayName.lowercased().contains(q) ||
|
||||
$0.benefAccount.contains(q) ||
|
||||
($0.benefNickName?.lowercased().contains(q) ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
private static func saveBmlSession(_ sess: BmlSession, profileId: String) {
|
||||
try? CredentialStore.shared.save(sess.accessToken, forKey: CredentialStore.Keys.bmlAccessToken(profileId))
|
||||
try? CredentialStore.shared.save(sess.deviceId, forKey: CredentialStore.Keys.bmlDeviceId(profileId))
|
||||
if !sess.refreshToken.isEmpty {
|
||||
try? CredentialStore.shared.save(sess.refreshToken, forKey: CredentialStore.Keys.bmlRefreshToken(profileId))
|
||||
}
|
||||
if sess.expiresAt > 0 {
|
||||
try? CredentialStore.shared.save(String(sess.expiresAt), forKey: CredentialStore.Keys.bmlExpTime(profileId))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class LockViewModel {
|
||||
private static let maxAttempts = 5
|
||||
private static let lockoutDuration = 30.0
|
||||
|
||||
var pinDigits: [Int] = []
|
||||
var hintMessage = ""
|
||||
var isVerifying = false
|
||||
|
||||
private let autoUnlock: Bool
|
||||
private(set) var pinLength: Int
|
||||
|
||||
init() {
|
||||
self.autoUnlock = UserDefaults.standard.bool(forKey: "auto_unlock_pin")
|
||||
self.pinLength = max(4, UserDefaults.standard.integer(forKey: "pin_length"))
|
||||
}
|
||||
|
||||
// MARK: - Display
|
||||
|
||||
var dotsDisplay: String {
|
||||
let n = pinDigits.count
|
||||
let total = autoUnlock ? pinLength : max(n, 4)
|
||||
return String(repeating: "●", count: n)
|
||||
+ String(repeating: "○", count: max(total - n, 0))
|
||||
}
|
||||
|
||||
// MARK: - Lockout
|
||||
|
||||
var lockoutRemaining: TimeInterval {
|
||||
let fails = UserDefaults.standard.integer(forKey: "lock_fail_count")
|
||||
guard fails >= Self.maxAttempts else { return 0 }
|
||||
let lastFail = UserDefaults.standard.double(forKey: "lock_last_fail_time")
|
||||
let elapsed = Date().timeIntervalSince1970 - lastFail
|
||||
return max(0, Self.lockoutDuration - elapsed)
|
||||
}
|
||||
|
||||
var isLockedOut: Bool { lockoutRemaining > 0 }
|
||||
|
||||
func resetFailures() {
|
||||
UserDefaults.standard.removeObject(forKey: "lock_fail_count")
|
||||
UserDefaults.standard.removeObject(forKey: "lock_last_fail_time")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func incrementFailures() -> Int {
|
||||
let fails = UserDefaults.standard.integer(forKey: "lock_fail_count") + 1
|
||||
UserDefaults.standard.set(fails, forKey: "lock_fail_count")
|
||||
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lock_last_fail_time")
|
||||
return fails
|
||||
}
|
||||
|
||||
// MARK: - Key handling
|
||||
|
||||
// Returns true when PIN should be submitted
|
||||
func handleKey(_ key: String) -> Bool {
|
||||
guard !isVerifying else { return false }
|
||||
switch key {
|
||||
case "⌫":
|
||||
if !pinDigits.isEmpty { pinDigits.removeLast() }
|
||||
case "✓":
|
||||
if pinDigits.count >= 4 { return true }
|
||||
default:
|
||||
if let d = Int(key), pinDigits.count < 8 {
|
||||
pinDigits.append(d)
|
||||
if autoUnlock && pinDigits.count == pinLength { return true }
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func currentPin() -> String { pinDigits.map(String.init).joined() }
|
||||
func clearPin() { pinDigits = [] }
|
||||
|
||||
func failureHint() -> String {
|
||||
let fails = incrementFailures()
|
||||
let remaining = Self.maxAttempts - fails
|
||||
if remaining <= 0 {
|
||||
return "Too many attempts. Try again in \(Int(Self.lockoutDuration))s."
|
||||
}
|
||||
return "\(remaining) attempt\(remaining == 1 ? "" : "s") remaining"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class LoginViewModel {
|
||||
|
||||
enum LoginState {
|
||||
case idle
|
||||
case loading(String)
|
||||
case fahipayNeedTotp
|
||||
case error(String)
|
||||
}
|
||||
|
||||
var state: LoginState = .idle
|
||||
|
||||
// Persisted across navigation for Fahipay 2-step flow
|
||||
private var fahipayFlow: FahipayLoginFlow?
|
||||
private var fahipayIdCard = ""
|
||||
private var fahipayPassword = ""
|
||||
|
||||
// MARK: - MIB
|
||||
|
||||
func loginMib(username: String, password: String, otpSeed: String) async throws -> [BankAccount] {
|
||||
let hash = MibCrypto.hashPassword(password)
|
||||
let flow = MibLoginFlow()
|
||||
let accounts = try await flow.login(username: username, passwordHash: hash, otpSeed: otpSeed)
|
||||
|
||||
try? CredentialStore.shared.save(hash, forKey: CredentialStore.Keys.mibPassword(username))
|
||||
try? CredentialStore.shared.save(otpSeed, forKey: CredentialStore.Keys.mibOtpSeed(username))
|
||||
try? CredentialStore.shared.addLoginId(username, toBank: "MIB")
|
||||
|
||||
return accounts
|
||||
}
|
||||
|
||||
// MARK: - BML
|
||||
|
||||
func loginBml(username: String, password: String, otpSeed: String) async throws -> [BankAccount] {
|
||||
let loginTag = "bml_\(username)"
|
||||
let flow = BmlLoginFlow()
|
||||
let profiles = try await flow.login(username: username, password: password, otpSeed: otpSeed)
|
||||
guard !profiles.isEmpty else { throw BmlError.loginFailed("No profiles found for this account") }
|
||||
|
||||
var accumulated: [BankAccount] = []
|
||||
for profile in profiles where profile.profileType != "business" {
|
||||
let result = try await flow.activateProfile(profile, loginTag: loginTag)
|
||||
if case .success(let session, let accs) = result {
|
||||
accumulated += accs
|
||||
saveSession(session, profileId: profile.profileId)
|
||||
}
|
||||
}
|
||||
|
||||
try? CredentialStore.shared.save(password, forKey: CredentialStore.Keys.bmlPassword(username))
|
||||
try? CredentialStore.shared.save(otpSeed, forKey: CredentialStore.Keys.bmlOtpSeed(username))
|
||||
try? CredentialStore.shared.addLoginId(username, toBank: "BML")
|
||||
let ids = profiles.map { $0.profileId }
|
||||
try? CredentialStore.shared.saveStringArray(ids, forKey: CredentialStore.Keys.bmlProfiles(username))
|
||||
|
||||
return accumulated
|
||||
}
|
||||
|
||||
private func saveSession(_ session: BmlSession, profileId: String) {
|
||||
try? CredentialStore.shared.save(session.accessToken, forKey: CredentialStore.Keys.bmlAccessToken(profileId))
|
||||
try? CredentialStore.shared.save(session.deviceId, forKey: CredentialStore.Keys.bmlDeviceId(profileId))
|
||||
if !session.refreshToken.isEmpty {
|
||||
try? CredentialStore.shared.save(session.refreshToken, forKey: CredentialStore.Keys.bmlRefreshToken(profileId))
|
||||
}
|
||||
if session.expiresAt > 0 {
|
||||
try? CredentialStore.shared.save(String(session.expiresAt), forKey: CredentialStore.Keys.bmlExpTime(profileId))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fahipay step 1
|
||||
|
||||
func loginFahipay(idCard: String, password: String) async throws -> FahipayLoginStep {
|
||||
let flow = FahipayLoginFlow()
|
||||
fahipayFlow = flow
|
||||
fahipayIdCard = idCard
|
||||
fahipayPassword = password
|
||||
return try await flow.login(idCard: idCard, password: password, deviceUuid: fahipayDeviceUuid())
|
||||
}
|
||||
|
||||
// MARK: - Fahipay step 2 (TOTP)
|
||||
|
||||
func verifyFahipayTotp(_ code: String) async throws {
|
||||
guard let flow = fahipayFlow else { throw FahipayError.sessionExpired }
|
||||
let authId = try await flow.verifyTotp(code: code, deviceUuid: fahipayDeviceUuid())
|
||||
let cookie = flow.getSessionCookieValue() ?? ""
|
||||
saveFahipaySession(authId: authId, sessionCookie: cookie)
|
||||
}
|
||||
|
||||
// Called when no 2FA is needed (authId already returned from step 1)
|
||||
func completeFahipayLogin(authId: String) {
|
||||
let cookie = fahipayFlow?.getSessionCookieValue() ?? ""
|
||||
saveFahipaySession(authId: authId, sessionCookie: cookie)
|
||||
}
|
||||
|
||||
private func saveFahipaySession(authId: String, sessionCookie: String) {
|
||||
// Use authId as the persisted login identifier
|
||||
let loginId = authId
|
||||
try? CredentialStore.shared.save(fahipayIdCard, forKey: CredentialStore.Keys.fahipayIdCard(loginId))
|
||||
try? CredentialStore.shared.save(fahipayPassword, forKey: CredentialStore.Keys.fahipayPassword(loginId))
|
||||
try? CredentialStore.shared.save(sessionCookie, forKey: CredentialStore.Keys.fahipaySessionCookie(loginId))
|
||||
try? CredentialStore.shared.save(authId, forKey: CredentialStore.Keys.fahipayAuthId(loginId))
|
||||
try? CredentialStore.shared.addLoginId(loginId, toBank: "FAHIPAY")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func fahipayDeviceUuid() -> String {
|
||||
let key = "fahipay_device_uuid"
|
||||
if let existing = CredentialStore.shared.load(forKey: key) { return existing }
|
||||
let uuid = FahipayLoginFlow.generateDeviceUuid()
|
||||
try? CredentialStore.shared.save(uuid, forKey: key)
|
||||
return uuid
|
||||
}
|
||||
|
||||
// Strips otpauth:// URI and whitespace/dashes, uppercases — mirrors Android resolveOtpSeed
|
||||
static func resolveOtpSeed(_ raw: String) -> String {
|
||||
var secret = raw.trimmingCharacters(in: .whitespaces)
|
||||
if secret.lowercased().hasPrefix("otpauth://totp/"),
|
||||
let url = URLComponents(string: secret),
|
||||
let s = url.queryItems?.first(where: { $0.name == "secret" })?.value {
|
||||
secret = s
|
||||
}
|
||||
return secret.replacingOccurrences(of: " ", with: "")
|
||||
.replacingOccurrences(of: "-", with: "")
|
||||
.uppercased()
|
||||
}
|
||||
|
||||
// Returns true if the string looks like a valid Base32 OTP seed (not a 6-digit code)
|
||||
static func isValidOtpSeed(_ raw: String) -> Bool {
|
||||
let seed = resolveOtpSeed(raw)
|
||||
if seed.isEmpty { return false }
|
||||
if seed.count == 6 && seed.allSatisfy({ $0.isNumber }) { return false } // reject plain codes
|
||||
let base32 = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=")
|
||||
return seed.unicodeScalars.allSatisfy { base32.contains($0) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
// Pending BML transfer kept between step 1 (initiate) and step 2 (OTP confirm).
|
||||
struct BmlPendingTransfer: Sendable {
|
||||
let fromAccount: BankAccount
|
||||
let toAccount: String
|
||||
let toName: String
|
||||
let amount: Double
|
||||
let currency: String
|
||||
let remarks: String
|
||||
let trnType: String
|
||||
let bank: String?
|
||||
let bmlSession: BmlSession
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class TransferViewModel {
|
||||
|
||||
// MARK: - Form state
|
||||
|
||||
var fromAccount: BankAccount? = nil
|
||||
var toInput: String = ""
|
||||
var amountInput: String = ""
|
||||
var purposeInput: String = ""
|
||||
|
||||
// MARK: - Lookup
|
||||
|
||||
enum LookupState {
|
||||
case idle
|
||||
case loading
|
||||
case found(MibAccountLookupResult) // MIB lookup result with full details
|
||||
case bmlReady(String) // BML: account name returned by server
|
||||
case error(String)
|
||||
}
|
||||
var lookupState: LookupState = .idle
|
||||
|
||||
// MARK: - Transfer flow
|
||||
|
||||
enum TransferState: Equatable {
|
||||
case idle
|
||||
case bmlStep2 // BML 2-step: awaiting OTP entry
|
||||
case processing
|
||||
case success(TransferReceiptData)
|
||||
case failure(String)
|
||||
}
|
||||
var transferState: TransferState = .idle
|
||||
|
||||
private var bmlPending: BmlPendingTransfer? = nil
|
||||
var bmlTrnType: String = "IAT" // set from lookup result; "IAT" BML-to-BML, "QTR" Favara, "DOT" other bank
|
||||
var bmlBank: String? = nil // Kotlin sends bank="MIB" for MVR BML-to-MIB DOT transfers
|
||||
var bmlCreditAccountOverride: String? = nil
|
||||
|
||||
// MARK: - Derived
|
||||
|
||||
var parsedAmount: Double {
|
||||
Double(amountInput.replacingOccurrences(of: ",", with: "")) ?? 0
|
||||
}
|
||||
|
||||
var canLookup: Bool {
|
||||
guard let bank = fromAccount?.bank else { return false }
|
||||
return (bank == "MIB" || bank == "BML") && !toInput.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
var canTransfer: Bool {
|
||||
fromAccount != nil && parsedAmount > 0 && hasValidLookup
|
||||
}
|
||||
|
||||
private var hasValidLookup: Bool {
|
||||
switch lookupState {
|
||||
case .found, .bmlReady: return true
|
||||
default:
|
||||
// BML: allow submit without explicit lookup (server validates)
|
||||
return fromAccount?.bank == "BML" && !toInput.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Beneficiary lookup
|
||||
|
||||
func lookupBeneficiary() async {
|
||||
guard let from = fromAccount else { return }
|
||||
let query = toInput.trimmingCharacters(in: .whitespaces)
|
||||
guard !query.isEmpty else { return }
|
||||
lookupState = .loading
|
||||
|
||||
if from.bank == "MIB" {
|
||||
do {
|
||||
let session = try await loadMibSession(loginTag: from.loginTag)
|
||||
let client = MibTransferClient(session: session)
|
||||
let result = try await client.lookupAccount(query)
|
||||
lookupState = .found(result)
|
||||
} catch {
|
||||
lookupState = .error(error.localizedDescription)
|
||||
}
|
||||
} else if from.bank == "BML" {
|
||||
do {
|
||||
let sess = try await loadBmlSession(loginTag: from.loginTag)
|
||||
let client = BmlTransferClient(bmlSession: sess)
|
||||
let result = try await client.lookupAccount(query)
|
||||
bmlTrnType = result.trnType
|
||||
bmlBank = result.bank
|
||||
toInput = result.accountNumber // use resolved account number
|
||||
lookupState = .bmlReady(result.accountName)
|
||||
} catch {
|
||||
lookupState = .error(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Submit transfer
|
||||
|
||||
func submitTransfer() async {
|
||||
guard let from = fromAccount, parsedAmount > 0 else { return }
|
||||
transferState = .processing
|
||||
|
||||
do {
|
||||
switch from.bank {
|
||||
case "MIB":
|
||||
let receipt = try await performMibTransfer(from: from)
|
||||
transferState = .success(receipt)
|
||||
|
||||
case "BML":
|
||||
try await initiateBmlTransfer(from: from)
|
||||
transferState = .bmlStep2
|
||||
|
||||
default:
|
||||
transferState = .failure("Transfers not yet supported for \(from.bank)")
|
||||
}
|
||||
} catch {
|
||||
transferState = .failure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BML OTP confirm
|
||||
|
||||
func confirmBmlOtp(otpSeed: String) async {
|
||||
guard let pending = bmlPending else { return }
|
||||
transferState = .processing
|
||||
let otp = Totp.generate(otpSeed)
|
||||
do {
|
||||
let receipt = try await finalizeBmlTransfer(pending: pending, otp: otp)
|
||||
transferState = .success(receipt)
|
||||
bmlPending = nil
|
||||
} catch {
|
||||
transferState = .failure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
fromAccount = nil
|
||||
toInput = ""
|
||||
amountInput = ""
|
||||
purposeInput = ""
|
||||
lookupState = .idle
|
||||
transferState = .idle
|
||||
bmlPending = nil
|
||||
bmlTrnType = "IAT"
|
||||
bmlBank = nil
|
||||
bmlCreditAccountOverride = nil
|
||||
}
|
||||
|
||||
// MARK: - MIB transfer
|
||||
|
||||
private func performMibTransfer(from: BankAccount) async throws -> TransferReceiptData {
|
||||
guard case .found(let lookup) = lookupState else {
|
||||
throw MibError.invalidResponse
|
||||
}
|
||||
let username = from.loginTag.replacingOccurrences(of: "mib_", with: "")
|
||||
guard let hash = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibPassword(username)),
|
||||
let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibOtpSeed(username)) else {
|
||||
throw MibError.serverError("MIB credentials not found. Please log in again.")
|
||||
}
|
||||
// Generate TOTP on @MainActor before entering actor context.
|
||||
let otp = Totp.generate(seed)
|
||||
let flow = MibLoginFlow()
|
||||
_ = try await flow.login(username: username, passwordHash: hash, otpSeed: seed)
|
||||
guard let session = await flow.lastSession else { throw MibError.sessionExpired }
|
||||
|
||||
let client = MibTransferClient(session: session)
|
||||
let result = try await client.executeTransfer(
|
||||
from: from,
|
||||
to: lookup,
|
||||
amount: parsedAmount,
|
||||
currency: from.currencyName,
|
||||
purpose: purposeInput,
|
||||
otp: otp
|
||||
)
|
||||
return TransferReceiptData(
|
||||
fromAccountNumber: from.accountNumber,
|
||||
fromBankName: "Maldives Islamic Bank",
|
||||
toAccountNumber: lookup.accountNumber,
|
||||
toAccountName: lookup.accountName,
|
||||
amount: parsedAmount,
|
||||
currency: from.currencyName,
|
||||
reference: result.trxId,
|
||||
date: result.date,
|
||||
message: "Transfer successful"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - BML transfer
|
||||
|
||||
private func initiateBmlTransfer(from: BankAccount) async throws {
|
||||
guard from.profileType == "BML" else {
|
||||
throw BmlError.serverError("BML card accounts cannot be used as the debit account for transfers. Select a BML account instead.")
|
||||
}
|
||||
let sess = try await loadBmlSession(loginTag: from.loginTag)
|
||||
let source = try await resolveBmlDebitSource(from, session: sess)
|
||||
let client = BmlTransferClient(bmlSession: sess)
|
||||
let overrideAccount = bmlCreditAccountOverride?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let toAcc = overrideAccount.isEmpty ? toInput.trimmingCharacters(in: .whitespaces) : overrideAccount
|
||||
let toName: String = {
|
||||
if case .bmlReady(let n) = lookupState, !n.isEmpty { return n }
|
||||
return ""
|
||||
}()
|
||||
|
||||
_ = try await client.initiateTransfer(
|
||||
fromAccount: source,
|
||||
toAccount: toAcc,
|
||||
amount: parsedAmount,
|
||||
currency: source.currencyName,
|
||||
trnType: bmlTrnType,
|
||||
bank: bmlBank
|
||||
)
|
||||
bmlPending = BmlPendingTransfer(
|
||||
fromAccount: source,
|
||||
toAccount: toAcc,
|
||||
toName: toName,
|
||||
amount: parsedAmount,
|
||||
currency: source.currencyName,
|
||||
remarks: purposeInput,
|
||||
trnType: bmlTrnType,
|
||||
bank: bmlBank,
|
||||
bmlSession: sess
|
||||
)
|
||||
}
|
||||
|
||||
private func resolveBmlDebitSource(_ account: BankAccount, session: BmlSession) async throws -> BankAccount {
|
||||
let currentId = (account.internalId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !currentId.isEmpty { return account }
|
||||
|
||||
let freshAccounts = try await BmlAccountClient(session: session).fetchAccounts(
|
||||
loginTag: account.loginTag,
|
||||
profileId: account.profileId ?? ""
|
||||
)
|
||||
if let match = freshAccounts.first(where: {
|
||||
$0.accountNumber == account.accountNumber &&
|
||||
$0.profileType == account.profileType &&
|
||||
!(($0.internalId ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}) {
|
||||
return match
|
||||
}
|
||||
throw BmlError.serverError("BML debit account id missing. Refresh dashboard and select the account again.")
|
||||
}
|
||||
|
||||
private func finalizeBmlTransfer(pending: BmlPendingTransfer, otp: String) async throws -> TransferReceiptData {
|
||||
let client = BmlTransferClient(bmlSession: pending.bmlSession)
|
||||
let receipt = try await client.confirmTransfer(
|
||||
fromAccount: pending.fromAccount,
|
||||
toAccount: pending.toAccount,
|
||||
amount: pending.amount,
|
||||
currency: pending.currency,
|
||||
trnType: pending.trnType,
|
||||
remarks: pending.remarks,
|
||||
otp: otp,
|
||||
bank: pending.bank
|
||||
)
|
||||
return TransferReceiptData(
|
||||
fromAccountNumber: pending.fromAccount.accountNumber,
|
||||
fromBankName: "Bank of Maldives",
|
||||
toAccountNumber: pending.toAccount,
|
||||
toAccountName: pending.toName,
|
||||
amount: pending.amount,
|
||||
currency: pending.currency,
|
||||
reference: receipt.reference,
|
||||
date: receipt.timestamp,
|
||||
message: receipt.message
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Session loaders
|
||||
|
||||
private func loadMibSession(loginTag: String) async throws -> MibSession {
|
||||
let username = loginTag.replacingOccurrences(of: "mib_", with: "")
|
||||
guard let hash = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibPassword(username)),
|
||||
let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibOtpSeed(username)) else {
|
||||
throw MibError.serverError("MIB credentials not found")
|
||||
}
|
||||
let flow = MibLoginFlow()
|
||||
_ = try await flow.login(username: username, passwordHash: hash, otpSeed: seed)
|
||||
guard let session = await flow.lastSession else { throw MibError.sessionExpired }
|
||||
return session
|
||||
}
|
||||
|
||||
private func loadBmlSession(loginTag: String) async throws -> BmlSession {
|
||||
let username = loginTag.replacingOccurrences(of: "bml_", with: "")
|
||||
let pids = CredentialStore.shared.loadStringArray(forKey: CredentialStore.Keys.bmlProfiles(username))
|
||||
for pid in pids {
|
||||
guard let token = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlAccessToken(pid)),
|
||||
!token.isEmpty else { continue }
|
||||
let expStr = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlExpTime(pid)) ?? "0"
|
||||
let expiresAt = Int64(expStr) ?? 0
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let deviceId = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlDeviceId(pid)) ?? ""
|
||||
let refreshTok = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlRefreshToken(pid)) ?? ""
|
||||
if expiresAt == 0 || now < expiresAt - 60_000 {
|
||||
return BmlSession(accessToken: token, deviceId: deviceId, refreshToken: refreshTok, expiresAt: expiresAt)
|
||||
}
|
||||
// Token near expiry — refresh
|
||||
let stale = BmlSession(accessToken: token, deviceId: deviceId, refreshToken: refreshTok, expiresAt: expiresAt)
|
||||
return try await BmlLoginFlow().refreshSession(stale)
|
||||
}
|
||||
// Fall back to full re-login
|
||||
guard let pw = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlPassword(username)),
|
||||
let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlOtpSeed(username)) else {
|
||||
throw BmlError.serverError("BML credentials not found")
|
||||
}
|
||||
let flow = BmlLoginFlow()
|
||||
let profiles = try await flow.login(username: username, password: pw, otpSeed: seed)
|
||||
guard let profile = profiles.first else { throw BmlError.serverError("No BML profile found") }
|
||||
if case .success(let sess, _) = try await flow.activateProfile(profile, loginTag: loginTag) {
|
||||
return sess
|
||||
}
|
||||
throw BmlError.serverError("BML session could not be established")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user