// // MandiriEmoneyApi.swift // Emoney Info // // Created by Wira Irawan on 27/07/24. // import Foundation import CoreNFC public class MandiriEmoneyApi : UnifiedNfcApi { var emoney : Emoney = Emoney() var riwayatList: [RiwayatCard] = [] var cardType : Int? var mapv1 : String = "" var start = 0 var finish = 256 var finish2 = 10 private var historyRetryCount = 0 private let useMandiriSpecHistoryReader = true private let mandiriSpecMaxPage = 4 private let mandiriSpecEmptyPageStopThreshold = 2 public override init() {} private func logMandiri(_ message: String) { _ = message // print("[Mandiri] \(message)") // debugLog("[Mandiri] \(message)") } private func logMandiriApdu(_ label: String, command: NFCISO7816APDU) { logMandiri("APDU -> \(label) | \(command.toHexString())") } private func logMandiriResponse(_ label: String, response: ApduResponse) { logMandiri( String( format: "APDU <- %@ | sw1=%02X sw2=%02X data=%@", label, response.sw1 ?? 0, response.sw2 ?? 0, response.getData().hexEncodedString() ) ) } public func getCardNumber(){ logMandiriApdu("MANDIRI_APDU01", command: EmoneyApduCommands.MANDIRI_APDU01) apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.MANDIRI_APDU01, completionHandler: {response in self.logMandiriResponse("MANDIRI_APDU01", response: response) if (response.sw1 == 0x90 && response.sw2 == 0x00){ let raw = response.getData().hexEncodedString() self.emoney.setCardLabel("Mandiri e-Money") self.emoney.setCardNumber(raw.subString(from: 0, to: 16)) self.cardType = raw.subString(from: 36, to: 38).hex2decimal() self.logMandiri("Card info raw=\(raw)") self.logMandiri("Card parsed | number=\(self.emoney.getCardNumber()) | cardType=\(self.cardType ?? -1)") self.getBalance() } else { self.logMandiri("Card info failed | status=\(response.sw1)-\(response.sw2)") self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) } }) } private func getBalance(){ logMandiriApdu("MANDIRI_APDU02", command: EmoneyApduCommands.MANDIRI_APDU02) apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.MANDIRI_APDU02, completionHandler: {response in self.logMandiriResponse("MANDIRI_APDU02", response: response) if (response.sw1 == 0x90 && response.sw2 == 0x00){ let raw = response.getData().hexEncodedString() let balance = raw.subString(from: 0, to: 8) self.emoney.setBalance(self.getRealBalance(reverseHexa: balance)) self.logMandiri("Balance raw=\(raw)") self.logMandiri("Balance parsed=\(self.emoney.getBalance())") self.resetHistoryState() self.historyRetryCount = 0 self.startHistoryRead() } else { self.logMandiri("Balance read failed | status=\(response.sw1)-\(response.sw2)") self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) } }) } private func startHistoryRead() { resetHistoryState() logMandiri("Start history read | retry=\(historyRetryCount) | cardType=\(cardType ?? -1) | useSpec=\(useMandiriSpecHistoryReader)") if useMandiriSpecHistoryReader { startMandiriSpecHistoryRead() return } if (self.cardType == 131){ self.getLogStep01(index: self.start) } else { self.getLogStep02(index: self.start) } } private func retryHistoryRead(reason: String, retryable: Bool = true) { guard retryable else { logMandiri("History read not retryable | reason=\(reason)") finalizeHistoryResult() return } if historyRetryCount < 1 { historyRetryCount += 1 logMandiri("Retrying history read | reason=\(reason) | retry=\(historyRetryCount)") startHistoryRead() } else { logMandiri("Retry exhausted | reason=\(reason)") finalizeHistoryResult() } } private func finalizeHistoryResult() { self.start = 0 self.finalizeHistory() self.emoney.setRiwayatList(self.riwayatList) self.emoney.setTampilRiwayat(!self.riwayatList.isEmpty) self.logMandiri("Finalize history | count=\(self.riwayatList.count)") self.updateScreen() self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) self.apduRunner.invalidateSession() } // MARK: - New Mandiri Spec Reader // Toggle `useMandiriSpecHistoryReader` to rollback to the legacy implementation. private func startMandiriSpecHistoryRead() { logMandiri("Start spec history read | pages=1...\(mandiriSpecMaxPage)") readMandiriSpecPage(page: 1, collectedChunks: [], emptyPageStreak: 0) } private func readMandiriSpecPage(page: Int, collectedChunks: [String], emptyPageStreak: Int) { guard page <= mandiriSpecMaxPage else { probeMandiriSpecStop(collectedChunks: collectedChunks) return } let command = NFCISO7816APDU( instructionClass: 0x00, instructionCode: 0xD1, p1Parameter: UInt8(page & 0xFF), p2Parameter: 0x00, data: Data(), expectedResponseLength: CommonConstants.LE_GET_ALL_RESPONSE_DATA ) logMandiriApdu("MANDIRI_SPEC_PAGE[\(page)]", command: command) apduRunner.exchangeApdu(apduCommand: command, completionHandler: { response in self.logMandiriResponse("MANDIRI_SPEC_PAGE[\(page)]", response: response) if response.sw1 == 0x90 && response.sw2 == 0x00 { let chunk = response.getData().hexEncodedString() let isEmptyPage = self.isMandiriSpecPageEmpty(chunk) self.logMandiri("Spec page chunk | page=\(page) | rawLength=\(chunk.count) | empty=\(isEmptyPage)") var nextChunks = collectedChunks nextChunks.append(chunk) let nextEmptyPageStreak = isEmptyPage ? (emptyPageStreak + 1) : 0 if nextEmptyPageStreak >= self.mandiriSpecEmptyPageStopThreshold { self.logMandiri("Spec page empty streak reached threshold | streak=\(nextEmptyPageStreak) | stopAfterPage=\(page)") self.probeMandiriSpecStop(collectedChunks: nextChunks) return } self.readMandiriSpecPage(page: page + 1, collectedChunks: nextChunks, emptyPageStreak: nextEmptyPageStreak) } else { self.logMandiri("Spec page read failed | page=\(page) | status=\(response.sw1)-\(response.sw2)") self.retryHistoryRead(reason: "spec page status \(response.sw1)-\(response.sw2) at page \(page)") } }) } private func probeMandiriSpecStop(collectedChunks: [String]) { let probePage = mandiriSpecMaxPage + 1 let command = NFCISO7816APDU( instructionClass: 0x00, instructionCode: 0xD1, p1Parameter: UInt8(probePage & 0xFF), p2Parameter: 0x00, data: Data(), expectedResponseLength: CommonConstants.LE_GET_ALL_RESPONSE_DATA ) logMandiriApdu("MANDIRI_SPEC_PROBE[\(probePage)]", command: command) apduRunner.exchangeApdu(apduCommand: command, completionHandler: { response in self.logMandiriResponse("MANDIRI_SPEC_PROBE[\(probePage)]", response: response) if response.sw1 == 0x69 && response.sw2 == 0x86 { self.logMandiri("Spec probe stop confirmed | page=\(probePage)") } else { self.logMandiri("Spec probe unexpected status | page=\(probePage) | status=\(response.sw1)-\(response.sw2)") } self.mapv1 = collectedChunks.joined() if self.parseMandiriSpecLog() { self.finalizeHistoryResult() } else { self.retryHistoryRead(reason: "invalid spec history payload", retryable: false) } }) } private func parseMandiriSpecLog() -> Bool { let logs = self.mapv1.trimmingCharacters(in: .whitespacesAndNewlines) let recordHexLength = 48 logMandiri("Parse spec log | rawLength=\(logs.count)") guard logs.count >= recordHexLength, logs.count % recordHexLength == 0 else { logMandiri("Parse spec log invalid length | actual=\(logs.count) | recordHexLength=\(recordHexLength)") return false } let total = logs.count / recordHexLength logMandiri("Parse spec log totalRecords=\(total)") for i in 0.. RiwayatCard? { guard recordHex.count == 48 else { logMandiri("Spec record invalid length | index=\(index) | length=\(recordHex.count)") return nil } if isMandiriSpecRecordEmpty(recordHex) { logMandiri("Spec record empty | index=\(index)") return nil } let bytes = recordHex.hex2byte().bytes guard bytes.count == 24 else { logMandiri("Spec record invalid byte count | index=\(index) | bytes=\(bytes.count)") return nil } guard let time = decodeMandiriSpecTimestamp(bytes, index: index) else { logMandiri("Spec record invalid timestamp | index=\(index) | raw=\(recordHex)") return nil } let transactionType = bytes[15] let amount = decodeMandiriLittleEndianUInt32(Array(bytes[16...19])) let balanceAfter = decodeMandiriLittleEndianUInt32(Array(bytes[20...23])) let terminalData = Array(bytes[7...13]).map { String(format: "%02X", $0) }.joined() let riwayat = RiwayatCard() riwayat.setTransactionTime(time) riwayat.setAmount(amount) riwayat.setDesk("BAL_AFTER=\(balanceAfter)") riwayat.setLocationId(terminalData) if transactionType == 0x00 { riwayat.setProsesTipe(0) riwayat.setTitle("topup".localizeString(string: self.langCode!)) } else if transactionType == 0x20 { riwayat.setProsesTipe(1) riwayat.setTitle("payment".localizeString(string: self.langCode!)) } else { riwayat.setProsesTipe(-1) riwayat.setTitle("unknown".localizeString(string: self.langCode!)) } logMandiri( String( format: "Parsed spec log | index=%d | type=%02X | title=%@ | amount=%d | balanceAfter=%d | terminal=%@ | raw=%@", index, transactionType, riwayat.getTitle() ?? "-", amount, balanceAfter, terminalData, recordHex ) ) return riwayat } private func isMandiriSpecPageEmpty(_ pageHex: String) -> Bool { guard !pageHex.isEmpty else { return true } return !pageHex.contains { $0 != "0" } } private func isMandiriSpecRecordEmpty(_ recordHex: String) -> Bool { return !recordHex.contains { $0 != "0" } } private func decodeMandiriSpecTimestamp(_ bytes: [UInt8], index: Int) -> Date? { guard bytes.count >= 7 else { return nil } let values = bytes.prefix(7).map { decodeBcd($0) } guard values.allSatisfy({ $0 >= 0 }) else { logMandiri("BCD decode failed | index=\(index) | bytes=\(bytes.prefix(7).map { String(format: "%02X", $0) }.joined())") return nil } var components = DateComponents() components.calendar = Calendar(identifier: .gregorian) components.timeZone = TimeZone(identifier: "Asia/Jakarta") ?? .current components.year = 2000 + values[2] components.month = values[1] components.day = values[0] components.hour = values[3] components.minute = values[4] components.second = values[5] components.nanosecond = values[6] * 10_000_000 return components.date } private func decodeBcd(_ value: UInt8) -> Int { let high = Int((value & 0xF0) >> 4) let low = Int(value & 0x0F) guard high < 10, low < 10 else { return -1 } return (high * 10) + low } private func decodeMandiriLittleEndianUInt32(_ bytes: [UInt8]) -> Int { guard bytes.count >= 4 else { return 0 } return Int(bytes[0]) | (Int(bytes[1]) << 8) | (Int(bytes[2]) << 16) | (Int(bytes[3]) << 24) } private func getLogStep01(index : Int){ //00 d1 00 00 00 let hex = String(index, radix: 16) let p1Parameter = hex.hex2byte().bytes.first ?? 0x00 let MANDIRI_LOG = NFCISO7816APDU(instructionClass : 0x00, instructionCode : 0xD1, p1Parameter : p1Parameter, p2Parameter : 0x00, data : Data(), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) logMandiriApdu("MANDIRI_LOG_NEW[\(index)]", command: MANDIRI_LOG) apduRunner.exchangeApdu(apduCommand: MANDIRI_LOG, completionHandler: {response in self.logMandiriResponse("MANDIRI_LOG_NEW[\(index)]", response: response) if (response.sw1 == 0x90 && response.sw2 == 0x00){ let chunk = response.getData().hexEncodedString() self.mapv1.append(chunk) self.logMandiri("New log chunk | index=\(index) | rawLength=\(chunk.count) | accumulated=\(self.mapv1.count)") self.start+=1 if (self.start < (self.finish)){ self.getLogStep01(index: self.start) } else { if self.parseNewLog() { self.finalizeHistoryResult() } else { self.retryHistoryRead(reason: "invalid new history payload", retryable: false) } } } else { self.logMandiri("New log read failed | index=\(index) | status=\(response.sw1)-\(response.sw2)") self.retryHistoryRead(reason: "new history status \(response.sw1)-\(response.sw2) at index \(index)") } }) } func parseNewLog() -> Bool { let logs = self.mapv1.trimmingCharacters(in: .whitespacesAndNewlines) logMandiri("Parse new log | rawLength=\(logs.count)") if (logs.count % 48 != 0){ logMandiri("Parse new log invalid length | length=\(logs.count)") return false } let total = logs.count/48 logMandiri("Parse new log totalRecords=\(total)") for i in 0.. Bool { let logs = self.mapv1.trimmingCharacters(in: .whitespacesAndNewlines) logMandiri("Parse old log | rawLength=\(logs.count)") if (logs.count % 60 != 0){ logMandiri("Parse old log invalid length | length=\(logs.count)") return false } let total = logs.count/60 logMandiri("Parse old log totalRecords=\(total)") for i in 0.. Date?{ let dateFormatter2 = DateFormatter() dateFormatter2.locale = Locale(identifier: "en_US_POSIX") dateFormatter2.dateFormat = "HHmmss" guard let date12 = dateFormatter2.date(from: formatTime) else { return nil } dateFormatter2.dateFormat = "hh:mm a" let date22 = dateFormatter2.string(from: date12) let dateFormatter = DateFormatter() dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.dateFormat = "ddMMyy hh:mm a" return dateFormatter.date(from:(formatDate + " " + date22)) } func getRealBalance(reverseHexa: String?) -> Int { guard let reverseHexa = reverseHexa?.trimmingCharacters(in: .whitespacesAndNewlines), !reverseHexa.isEmpty else { return 0 } if reverseHexa.count % 2 != 0 { return 0 } var sb = "" for l in stride(from: reverseHexa.count / 2, through: 1, by: -1) { let index1 = reverseHexa.index(reverseHexa.startIndex, offsetBy: l * 2 - 2) let index2 = reverseHexa.index(reverseHexa.startIndex, offsetBy: l * 2 - 1) sb.append(reverseHexa[index1]) sb.append(reverseHexa[index2]) } return sb.hex2decimal() } private func updateScreen(){ if (self.apduRunner.callback != nil){ self.apduRunner.callback?.complete(emoney: self.emoney) } } private func resetHistoryState() { riwayatList.removeAll() mapv1 = "" start = 0 } private func finalizeHistory() { riwayatList = riwayatList.sorted { lhs, rhs in switch (lhs.getTransationTime(), rhs.getTransationTime()) { case let (left?, right?): return left.compare(right) == .orderedDescending case (_?, nil): return true case (nil, _?): return false case (nil, nil): return false } } } func riwayatCard(_ bArr: [UInt8]) -> RiwayatCard? { var str: String let riwayatCard = RiwayatCard() var wrap = Data(bArr) var bArr2 = [UInt8](repeating: 0, count: 6) var bArr3 = [UInt8](repeating: 0, count: 16) wrap.copyBytes(to: &bArr2, count: 6) wrap.removeFirst(10) let len = wrap.withUnsafeBytes { $0.load(as: Int32.self).bigEndian } wrap.removeFirst(4) let amount = wrap.withUnsafeBytes { $0.load(as: Int32.self).littleEndian } wrap.removeFirst(4) let desk = wrap.withUnsafeBytes { $0.load(as: Int32.self).littleEndian } wrap.removeFirst(4) do { wrap.copyBytes(to: &bArr3, count: 16) logMandiri("Old log detail bytes=\(bArr3.map { String(format: "%02X", $0) }.joined())") } catch { logMandiri("Old log detail error=\(error.localizedDescription)") } var type = 0 if len == 288 { str = "payment".localizeString(string: self.langCode!) type = 1 } else if len == 256 || len == 336 { type = 3 str = "topup".localizeString(string: self.langCode!) } else { type = -1 str = "unknown".localizeString(string: self.langCode!) } var str2 = "" for y in 0..<6 { str2 += String(format: "%02X", bArr2[y]) } guard let transactionTime = self.getTransactionTime(formatDate: str.subString(from: 0, to: 6), formatTime: str.subString(from: 6, to: 12)) else { logMandiri("Skip old log record | reason=invalid_timestamp | raw=\(str)") return nil } riwayatCard.setTransactionTime(transactionTime) riwayatCard.setAmount(Int(amount)) // if str.caseInsensitiveCompare("payment") == .orderedSame { // type = 1 // } else if str.caseInsensitiveCompare("topup") != .orderedSame { // type = 3 // } riwayatCard.setProsesTipe(type) riwayatCard.setTitle(str) if type == -1 { logMandiri("Skip old log record | reason=unknown_type | len=\(len) | raw=\(str)") return nil } return riwayatCard } }