// // BcaFlazzApi.swift // Emoney Info // // Created by Wira Irawan on 27/07/24. // import Foundation import CoreNFC public class BcaFlazzApi : UnifiedNfcApi { private let emoney: Emoney = Emoney() private var ef84Records: [RiwayatCard] = [] private var extendedRecords: [RiwayatCard] = [] private let ef84MaxSlots = 10 private let extendedMaxRecords = 256 public override init() {} public func checkFlazzCard() { selectDirectoryFile(useFallbackLe: false) } private func selectDirectoryFile(useFallbackLe: Bool) { let command = NFCISO7816APDU( instructionClass: 0x00, instructionCode: 0xA4, p1Parameter: 0x01, p2Parameter: 0x00, data: Data([0x02, 0x00]), expectedResponseLength: useFallbackLe ? 256 : -1 ) apduRunner.exchangeApdu(apduCommand: command, completionHandler: { response in if response.sw1 == 0x90 && response.sw2 == 0x00 { self.emoney.setCardLabel("BCA Flazz") self.getCardNumber() } else if !useFallbackLe { self.selectDirectoryFile(useFallbackLe: true) } else { self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) } }) } private func getCardNumber() { apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BCA_APDU02, completionHandler: { response in if response.sw1 == 0x90 && response.sw2 == 0x00 { guard let raw = String(data: response.getData(), encoding: .isoLatin1) else { self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) return } if let cardNumber = self.extractPan(from: raw) { self.emoney.setCardNumber(cardNumber) self.getBalance() } else { self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) } } else { self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) } }) } private func getBalance() { apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BCA_APDU03, completionHandler: { response in if response.sw1 == 0x90 && response.sw2 == 0x00 { let bytes = response.getData().bytes guard bytes.count >= 4 else { self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) return } self.emoney.setBalance(self.uint32BE(bytes, offset: 0)) self.readHistoryCheck() } else { self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) } }) } private func readHistoryCheck() { apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BCA_APDU04, completionHandler: { response in let shouldReadExtended = response.sw1 == 0x90 && response.sw2 == 0x00 self.readEf84Record(slot: 0, shouldReadExtended: shouldReadExtended) }) } private func readEf84Record(slot: Int, shouldReadExtended: Bool) { guard slot < ef84MaxSlots else { if shouldReadExtended { getChallenge() } else { finishReading() } return } let command = NFCISO7816APDU( instructionClass: 0x00, instructionCode: 0xB0, p1Parameter: 0x84, p2Parameter: UInt8(slot * 0x0F), data: Data(), expectedResponseLength: 60 ) apduRunner.exchangeApdu(apduCommand: command, completionHandler: { response in if response.sw1 == 0x90 && response.sw2 == 0x00 { if let record = self.parseEf84Record(response.getData()) { self.ef84Records.append(record) } self.readEf84Record(slot: slot + 1, shouldReadExtended: shouldReadExtended) } else if response.sw1 == 0x6B && response.sw2 == 0x00 { if shouldReadExtended { self.getChallenge() } else { self.finishReading() } } else { if shouldReadExtended { self.getChallenge() } else { self.finishReading() } } }) } private func getChallenge() { let command = NFCISO7816APDU( instructionClass: 0x00, instructionCode: 0x84, p1Parameter: 0x00, p2Parameter: 0x00, data: Data(), expectedResponseLength: 8 ) apduRunner.exchangeApdu(apduCommand: command, completionHandler: { response in if response.sw1 == 0x90 && response.sw2 == 0x00 { self.readExtendedRecord(index: 0) } else { self.finishReading() } }) } private func readExtendedRecord(index: Int) { guard index < extendedMaxRecords else { finishReading() return } let command = NFCISO7816APDU( instructionClass: 0x90, instructionCode: 0x32, p1Parameter: 0x03, p2Parameter: 0x00, data: Data([UInt8(index)]), expectedResponseLength: 32 ) apduRunner.exchangeApdu(apduCommand: command, completionHandler: { response in if response.sw1 == 0x90 && response.sw2 == 0x00 { if let record = self.parseExtendedRecord(response.getData()) { self.extendedRecords.append(record) } self.readExtendedRecord(index: index + 1) } else if response.sw1 == 0x6A && response.sw2 == 0x80 { self.finishReading() } else { self.finishReading() } }) } private func finishReading() { var history = ef84Records + extendedRecords history.sort { ($0.getTransationTime() ?? Date.distantPast) > ($1.getTransationTime() ?? Date.distantPast) } if !history.isEmpty { emoney.setRiwayatList(history) emoney.setTampilRiwayat(true) } updateScreen() apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) apduRunner.invalidateSession() } private func parseEf84Record(_ data: Data) -> RiwayatCard? { let bytes = data.bytes guard bytes.count >= 60 else { return nil } let item = RiwayatCard() let typeRaw = (Int(bytes[0]) << 8) | Int(bytes[1]) let amount = uint24BE(bytes, offset: 6) let transactionTime = uint32BE(bytes, offset: 38) let terminalId = asciiString(bytes, start: 30, length: 8) let isPayment = typeRaw == 0x0400 item.setAmount(amount) item.setLocationId(terminalId) item.setLocationName(isPayment ? "payment".localizeString(string: self.langCode!) : "topup".localizeString(string: self.langCode!)) item.setTransactionTime(formatFlazzTimestamp(seconds: transactionTime)) if isPayment { item.setProsesTipe(1) item.setTitle("payment".localizeString(string: self.langCode!)) } else { item.setProsesTipe(0) item.setTitle("topup".localizeString(string: self.langCode!)) } return transactionTime > 0 ? item : nil } private func parseExtendedRecord(_ data: Data) -> RiwayatCard? { let bytes = data.bytes guard bytes.count >= 32 else { return nil } let item = RiwayatCard() let type = bytes[0] let rawTimestamp = Array(bytes[4..<8]).hexString().uppercased() let timestampSeconds = uint32BE(bytes, offset: 4) let rawAmount = uint24BE(bytes, offset: 1) let terminalId = asciiString(bytes, start: 14, length: 8) let isPayment = type == 0x04 let amount = isPayment ? 0x1000000 - rawAmount : rawAmount item.setAmount(amount) item.setLocationId(terminalId) item.setLocationName(isPayment ? "payment".localizeString(string: self.langCode!) : "topup".localizeString(string: self.langCode!)) item.setDesk("RAW TS: \(rawTimestamp)") item.setTransactionTime(formatFlazzTimestamp(seconds: timestampSeconds)) if isPayment { item.setProsesTipe(1) item.setTitle("payment".localizeString(string: self.langCode!)) } else { item.setProsesTipe(0) item.setTitle("topup".localizeString(string: self.langCode!)) } return timestampSeconds > 0 ? item : nil } private func extractPan(from raw: String) -> String? { guard let start = raw.firstIndex(of: ";") else { return nil } let tail = raw[raw.index(after: start)...] guard let end = tail.firstIndex(of: "=") else { return nil } return String(tail[.. Int { guard bytes.count >= offset + 3 else { return 0 } return (Int(bytes[offset]) << 16) | (Int(bytes[offset + 1]) << 8) | Int(bytes[offset + 2]) } private func uint32BE(_ bytes: [UInt8], offset: Int) -> Int { guard bytes.count >= offset + 4 else { return 0 } return (Int(bytes[offset]) << 24) | (Int(bytes[offset + 1]) << 16) | (Int(bytes[offset + 2]) << 8) | Int(bytes[offset + 3]) } private func formatFlazzTimestamp(seconds: Int) -> Date { var dateComponents = DateComponents() dateComponents.year = 1980 dateComponents.month = 1 dateComponents.day = 1 dateComponents.timeZone = TimeZone(identifier: "Asia/Jakarta")! dateComponents.hour = 0 dateComponents.minute = 0 dateComponents.second = seconds return Calendar.current.date(from: dateComponents)! } private func asciiString(_ bytes: [UInt8], start: Int, length: Int) -> String { guard bytes.count >= start + length else { return "" } let slice = Array(bytes[start..<(start + length)]) return String(bytes: slice, encoding: .ascii)? .trimmingCharacters(in: .whitespacesAndNewlines.union(.controlCharacters)) ?? "" } private func updateScreen() { if self.apduRunner.callback != nil { self.apduRunner.callback?.complete(emoney: self.emoney) } } }