Adds BML contactless token fetching, APDU emulation utility, TapToPayViewModel, and TapToPayView with NFC animation. Also updates QR scanner, transfer flow, dashboard, and home views. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
116 lines
3.8 KiB
Swift
116 lines
3.8 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class TapToPayViewModel {
|
|
|
|
let account: BankAccount
|
|
|
|
var phase: Phase = .loading
|
|
var token: BmlWalletToken?
|
|
var errorMessage: String?
|
|
var isReady = false
|
|
|
|
enum Phase {
|
|
case loading
|
|
case ready
|
|
case error
|
|
}
|
|
|
|
init(account: BankAccount) {
|
|
self.account = account
|
|
}
|
|
|
|
func armToken() async {
|
|
phase = .loading
|
|
errorMessage = nil
|
|
BmlContactlessUtil.clearToken()
|
|
|
|
let loginId = account.loginTag.replacingOccurrences(of: "bml_", with: "")
|
|
|
|
guard let otpSeed = CredentialStore.shared.load(
|
|
forKey: CredentialStore.Keys.bmlOtpSeed(loginId)
|
|
) else {
|
|
errorMessage = "OTP seed not found for \(loginId). Please log in again."
|
|
phase = .error
|
|
return
|
|
}
|
|
|
|
let profileId = account.profileId ?? ""
|
|
|
|
guard let accessToken = CredentialStore.shared.load(
|
|
forKey: CredentialStore.Keys.bmlAccessToken(profileId)
|
|
), !accessToken.isEmpty else {
|
|
errorMessage = "Session not found. Please log in again."
|
|
phase = .error
|
|
return
|
|
}
|
|
|
|
let deviceId = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlDeviceId(profileId)) ?? ""
|
|
let refreshTok = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlRefreshToken(profileId)) ?? ""
|
|
let expStr = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlExpTime(profileId)) ?? "0"
|
|
let expiresAt = Int64(expStr) ?? 0
|
|
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
|
|
|
var session = BmlSession(
|
|
accessToken: accessToken,
|
|
deviceId: deviceId,
|
|
refreshToken: refreshTok,
|
|
expiresAt: expiresAt
|
|
)
|
|
|
|
// Refresh if expired or expiring within 60s
|
|
if expiresAt > 0 && now >= expiresAt - 60_000 {
|
|
do {
|
|
let fresh = try await BmlLoginFlow().refreshSession(session)
|
|
saveSession(fresh, profileId: profileId)
|
|
session = fresh
|
|
} catch {
|
|
errorMessage = "Session expired. Please log in again. (\(error.localizedDescription))"
|
|
phase = .error
|
|
return
|
|
}
|
|
}
|
|
|
|
let otp = Totp.generate(otpSeed)
|
|
let internalId = account.internalId ?? ""
|
|
|
|
do {
|
|
let tokens = try await BmlTapToPayClient().fetchTokens(
|
|
session: session,
|
|
cardId: internalId,
|
|
otp: otp
|
|
)
|
|
guard let firstToken = tokens.first else {
|
|
errorMessage = "No payment tokens received from server."
|
|
phase = .error
|
|
return
|
|
}
|
|
token = firstToken
|
|
BmlContactlessUtil.setToken(firstToken)
|
|
phase = .ready
|
|
isReady = true
|
|
} catch {
|
|
if error is CancellationError { return }
|
|
errorMessage = error.localizedDescription
|
|
phase = .error
|
|
}
|
|
}
|
|
|
|
func cancel() {
|
|
BmlContactlessUtil.clearToken()
|
|
}
|
|
|
|
private func saveSession(_ 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))
|
|
}
|
|
}
|
|
}
|