From bd34467ddcbd45daacaf9c666728282c41103f7d Mon Sep 17 00:00:00 2001 From: Wira Irawan Date: Fri, 8 May 2026 05:40:52 +0700 Subject: [PATCH] Improve NFC history readers and prepare production build --- Emoney Info.xcodeproj/project.pbxproj | 8 +- Emoney Info/Classes/api/BcaFlazzApi.swift | 33 +- Emoney Info/Classes/api/BrizziApi.swift | 98 +++- .../Classes/api/MandiriEmoneyApi.swift | 435 +++++++++++++++--- Emoney Info/Classes/api/TapCashApi.swift | 71 ++- Emoney Info/Classes/api/UnifiedNfcApi.swift | 117 +++-- .../Classes/api/nfc/ApduResponse.swift | 6 +- Emoney Info/Classes/api/nfc/ApduRunner.swift | 29 +- Emoney Info/Classes/api/nfc/Emoney.swift | 8 +- .../Classes/api/utils/BrizziSamHelper.swift | 23 +- .../api/utils/ByteArrayAndHexHelper.swift | 4 +- Emoney Info/Classes/extensions/String.swift | 28 +- .../Classes/smartCard/TapCashData.swift | 4 + Emoney Info/Classes/utils/ToastHelper.swift | 6 +- 14 files changed, 688 insertions(+), 182 deletions(-) diff --git a/Emoney Info.xcodeproj/project.pbxproj b/Emoney Info.xcodeproj/project.pbxproj index dcacfd7..1587836 100644 --- a/Emoney Info.xcodeproj/project.pbxproj +++ b/Emoney Info.xcodeproj/project.pbxproj @@ -623,7 +623,7 @@ CODE_SIGN_ENTITLEMENTS = "Emoney Info/Emoney Info.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 6S5573WXX4; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; @@ -643,7 +643,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.0.4; PRODUCT_BUNDLE_IDENTIFIER = com.iiyh.emoneyinfo; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -666,7 +666,7 @@ CODE_SIGN_ENTITLEMENTS = "Emoney Info/Emoney Info.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 6S5573WXX4; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; @@ -686,7 +686,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.3; + MARKETING_VERSION = 1.0.4; PRODUCT_BUNDLE_IDENTIFIER = com.iiyh.emoneyinfo; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Emoney Info/Classes/api/BcaFlazzApi.swift b/Emoney Info/Classes/api/BcaFlazzApi.swift index 7c67fab..412456b 100644 --- a/Emoney Info/Classes/api/BcaFlazzApi.swift +++ b/Emoney Info/Classes/api/BcaFlazzApi.swift @@ -12,6 +12,7 @@ public class BcaFlazzApi : UnifiedNfcApi { private let emoney: Emoney = Emoney() private var ef84Records: [RiwayatCard] = [] private var extendedRecords: [RiwayatCard] = [] + private var historyRetryCount = 0 private let ef84MaxSlots = 10 private let extendedMaxRecords = 256 @@ -74,6 +75,8 @@ public class BcaFlazzApi : UnifiedNfcApi { } self.emoney.setBalance(self.uint32BE(bytes, offset: 0)) + self.historyRetryCount = 0 + self.resetHistoryState() self.readHistoryCheck() } else { self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) @@ -81,6 +84,22 @@ public class BcaFlazzApi : UnifiedNfcApi { }) } + private func resetHistoryState() { + ef84Records.removeAll() + extendedRecords.removeAll() + } + + private func retryHistoryRead(reason: String) { + if historyRetryCount < 1 { + historyRetryCount += 1 + debugLog("Retrying Flazz history read: \(reason)") + resetHistoryState() + readHistoryCheck() + } else { + finishReading() + } + } + private func readHistoryCheck() { apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BCA_APDU04, completionHandler: { response in let shouldReadExtended = response.sw1 == 0x90 && response.sw2 == 0x00 @@ -120,11 +139,7 @@ public class BcaFlazzApi : UnifiedNfcApi { self.finishReading() } } else { - if shouldReadExtended { - self.getChallenge() - } else { - self.finishReading() - } + self.retryHistoryRead(reason: "EF84 status \(response.sw1)-\(response.sw2) at slot \(slot)") } }) } @@ -143,7 +158,7 @@ public class BcaFlazzApi : UnifiedNfcApi { if response.sw1 == 0x90 && response.sw2 == 0x00 { self.readExtendedRecord(index: 0) } else { - self.finishReading() + self.retryHistoryRead(reason: "challenge status \(response.sw1)-\(response.sw2)") } }) } @@ -172,7 +187,7 @@ public class BcaFlazzApi : UnifiedNfcApi { } else if response.sw1 == 0x6A && response.sw2 == 0x80 { self.finishReading() } else { - self.finishReading() + self.retryHistoryRead(reason: "extended status \(response.sw1)-\(response.sw2) at index \(index)") } }) } @@ -293,11 +308,11 @@ public class BcaFlazzApi : UnifiedNfcApi { dateComponents.year = 1980 dateComponents.month = 1 dateComponents.day = 1 - dateComponents.timeZone = TimeZone(identifier: "Asia/Jakarta")! + dateComponents.timeZone = TimeZone(identifier: "Asia/Jakarta") ?? .current dateComponents.hour = 0 dateComponents.minute = 0 dateComponents.second = seconds - return Calendar.current.date(from: dateComponents)! + return Calendar.current.date(from: dateComponents) ?? Date.distantPast } private func asciiString(_ bytes: [UInt8], start: Int, length: Int) -> String { diff --git a/Emoney Info/Classes/api/BrizziApi.swift b/Emoney Info/Classes/api/BrizziApi.swift index cd7b12e..326133d 100755 --- a/Emoney Info/Classes/api/BrizziApi.swift +++ b/Emoney Info/Classes/api/BrizziApi.swift @@ -13,6 +13,7 @@ public class BrizziApi : UnifiedNfcApi { var riwayatList: [RiwayatCard] = [] var uid : String? var rawLog : String = "" + private var historyRetryCount = 0 public override init() {} @@ -73,24 +74,44 @@ public class BrizziApi : UnifiedNfcApi { let brizziSamHelper = BrizziSamHelper() brizziSamHelper.keyCard = data.hexEncodedString() let random = "8DC0DC40FE1DC582CF7099E2AACFBC10".hex2byte() - let command = self.emoney.getCardNumber() + self.uid! + "FF" - let decrypted = BrizziSamHelper.decryptDeSeDe(random)?.hexEncodedString() + guard let uid = self.uid else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + return + } + let command = self.emoney.getCardNumber() + uid + "FF" + guard let decrypted = BrizziSamHelper.decryptDeSeDe(random)?.hexEncodedString() else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + return + } - let decryptedFinal = decrypted?.subString(from: 0, to: 32) - let encrypted = BrizziSamHelper.encryptDeSeDe(command, decryptedFinal!, "0000000000000000")?.hexEncodedString() + let decryptedFinal = decrypted.subString(from: 0, to: 32) + guard decryptedFinal.count == 32, + let encrypted = BrizziSamHelper.encryptDeSeDe(command, decryptedFinal, "0000000000000000")?.hexEncodedString() else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + return + } - brizziSamHelper.encryptedKey = encrypted?.subString(from: 0, to: 32) + brizziSamHelper.encryptedKey = encrypted.subString(from: 0, to: 32) let randomHex = "3C37029CA595FE4E7E62FCB2F7909B2C".hex2byte() let randomHexDecrypted = BrizziSamHelper.decryptDeSeDe(randomHex) + let randomHexFinal = randomHexDecrypted?.hexEncodedString().subString(from: 0, to: 32) ?? "" - let randomHexFinal = randomHexDecrypted?.hexEncodedString().subString(from: 0, to: 32) - let randomHexEncrypted = BrizziSamHelper.encryptDeSeDe(brizziSamHelper.encryptedKey!, randomHexFinal!, brizziSamHelper.authKey) - brizziSamHelper.random = (randomHexEncrypted?.hexEncodedString())!.subString(from: 0, to: 32) + guard randomHexFinal.count == 32, + let encryptedKey = brizziSamHelper.encryptedKey, + let randomHexEncrypted = BrizziSamHelper.encryptDeSeDe(encryptedKey, randomHexFinal, brizziSamHelper.authKey)?.hexEncodedString() else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + return + } + brizziSamHelper.random = randomHexEncrypted.subString(from: 0, to: 32) let samChallenge = brizziSamHelper.generateSamRandom().subString(from: 0, to: 32) + guard samChallenge.count == 32 else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + return + } let BRI_APDU04 = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0xAF, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(_: samChallenge.hex2byte()), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) apduRunner.exchangeApdu(apduCommand: BRI_APDU04, completionHandler: {response in if (response.sw1 == 0x91 && response.sw2 == 0x00) || (response.sw1 == 0x91 && response.sw2 == 0xAF){ @@ -105,12 +126,42 @@ public class BrizziApi : UnifiedNfcApi { apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BRI_APDU05, completionHandler: {response in if (response.sw1 == 0x91 && response.sw2 == 0x00) || (response.sw1 == 0x91 && response.sw2 == 0xAF){ self.emoney.setBalance(self.getRealBalance(reverseHexa: response.getData().hexEncodedString().subString(from: 0, to: 8))) - self.getLog() + self.historyRetryCount = 0 + self.startHistoryRead() } else { self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) } }) } + + private func startHistoryRead() { + rawLog = "" + riwayatList.removeAll() + getLog() + } + + private func retryHistoryRead(reason: String) { + if historyRetryCount < 1 { + historyRetryCount += 1 + debugLog("Retrying Brizzi history read: \(reason)") + startHistoryRead() + } else { + finalizeHistoryRead() + } + } + + private func finalizeHistoryRead() { + if (self.parseLog()){ + self.riwayatList = self.riwayatList.sorted { + ($0.getTransationTime() ?? Date.distantPast) > ($1.getTransationTime() ?? Date.distantPast) + } + self.emoney.setRiwayatList(self.riwayatList) + self.emoney.setTampilRiwayat(true) + } + self.updateScreen() + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } private func getLog(){ apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BRI_LOG01, completionHandler: {response in @@ -118,9 +169,7 @@ public class BrizziApi : UnifiedNfcApi { self.rawLog.append(response.getData().hexEncodedString()) self.getMoreLog() } else { - self.updateScreen() - self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) - self.apduRunner.invalidateSession() + self.retryHistoryRead(reason: "initial log status \(response.sw1)-\(response.sw2)") } }) } @@ -131,16 +180,12 @@ public class BrizziApi : UnifiedNfcApi { self.rawLog.append(response.getData().hexEncodedString()) self.getMoreLog() } else { - self.rawLog.append(response.getData().hexEncodedString()) - - if (self.parseLog()){ - self.riwayatList = self.riwayatList.sorted(by: { $0.getTransationTime()?.compare($1.getTransationTime()!) == .orderedDescending }) - self.emoney.setRiwayatList(self.riwayatList) - self.emoney.setTampilRiwayat(true) + if (response.sw1 == 0x91 && response.sw2 == 0x00) || (response.sw1 == 0x90 && response.sw2 == 0x00) { + self.rawLog.append(response.getData().hexEncodedString()) + self.finalizeHistoryRead() + } else { + self.retryHistoryRead(reason: "continuation log status \(response.sw1)-\(response.sw2)") } - self.updateScreen() - self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) - self.apduRunner.invalidateSession() } }) } @@ -163,7 +208,11 @@ public class BrizziApi : UnifiedNfcApi { riwayat.setAmount(self.getRealBalance(reverseHexa: data.subString(from: 46, to: 52))) let time = self.getTransactionTime(formatDate: data.subString(from: 32, to: 38), formatTime: data.subString(from: 38, to: 44)) - riwayat.setTransactionTime(time!) + guard let time else { + debugLog("Skipping Brizzi log with invalid timestamp: \(data)") + continue + } + riwayat.setTransactionTime(time) riwayatList.append(riwayat) } @@ -173,8 +222,11 @@ public class BrizziApi : UnifiedNfcApi { func getTransactionTime(formatDate : String, formatTime : String) -> Date?{ let dateFormatter2 = DateFormatter() + dateFormatter2.locale = Locale(identifier: "en_US_POSIX") dateFormatter2.dateFormat = "HHmmss" - let date12 = dateFormatter2.date(from: formatTime)! + guard let date12 = dateFormatter2.date(from: formatTime) else { + return nil + } dateFormatter2.dateFormat = "hh:mm a" let date22 = dateFormatter2.string(from: date12) diff --git a/Emoney Info/Classes/api/MandiriEmoneyApi.swift b/Emoney Info/Classes/api/MandiriEmoneyApi.swift index e078298..e8c6276 100755 --- a/Emoney Info/Classes/api/MandiriEmoneyApi.swift +++ b/Emoney Info/Classes/api/MandiriEmoneyApi.swift @@ -16,88 +16,375 @@ public class MandiriEmoneyApi : UnifiedNfcApi { 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(response.getData().hexEncodedString().subString(from: 0, to: 16)) - self.cardType = response.getData().hexEncodedString().subString(from: 36, to: 38).hex2decimal() - debugLog(self.cardType!) + 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){ - debugLog(response.getData().hexEncodedString()) - let balance = response.getData().hexEncodedString().subString(from: 0, to: 8) + 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.updateScreen() - if (self.cardType! == 131){ - self.getLogStep01(index: self.start) - } else { - self.getLogStep02(index: self.start) - } + 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 MANDIRI_LOG = NFCISO7816APDU(instructionClass : 0x00, instructionCode : 0xD1, p1Parameter : hex.hex2byte().bytes.first!, p2Parameter : 0x00, data : Data(), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + 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){ - self.mapv1.append(response.getData().hexEncodedString()) + 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 { - self.parseNewLog() - self.start = 0 - self.finalizeHistory() - self.emoney.setRiwayatList(self.riwayatList) - self.emoney.setTampilRiwayat(true) - self.updateScreen() - self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) - self.apduRunner.invalidateSession() + if self.parseNewLog() { + self.finalizeHistoryResult() + } else { + self.retryHistoryRead(reason: "invalid new history payload", retryable: false) + } } } else { - self.parseNewLog() - self.start = 0 - self.finalizeHistory() - self.emoney.setRiwayatList(self.riwayatList) - self.emoney.setTampilRiwayat(true) - self.updateScreen() - self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) - self.apduRunner.invalidateSession() + 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(){ + func parseNewLog() -> Bool { let logs = self.mapv1.trimmingCharacters(in: .whitespacesAndNewlines) + logMandiri("Parse new log | rawLength=\(logs.count)") if (logs.count % 48 != 0){ - return + 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){ - return + 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" - let date12 = dateFormatter2.date(from: formatTime)! + 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") // set locale to reliable US_POSIX - dateFormatter.dateFormat = "ddMMyy hh:mm a" + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "ddMMyy hh:mm a" return dateFormatter.date(from:(formatDate + " " + date22)) } @@ -207,7 +503,18 @@ public class MandiriEmoneyApi : UnifiedNfcApi { } private func finalizeHistory() { - riwayatList = riwayatList.sorted(by: { $0.getTransationTime()?.compare($1.getTransationTime()!) == .orderedDescending }) + 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? { @@ -229,9 +536,9 @@ public class MandiriEmoneyApi : UnifiedNfcApi { do { wrap.copyBytes(to: &bArr3, count: 16) - debugLog("bArr3: \(bArr3.map { String(format: "%02X", $0) }.joined())") + logMandiri("Old log detail bytes=\(bArr3.map { String(format: "%02X", $0) }.joined())") } catch { - debugLog("Error: \(error.localizedDescription)") + logMandiri("Old log detail error=\(error.localizedDescription)") } var type = 0 @@ -252,8 +559,11 @@ public class MandiriEmoneyApi : UnifiedNfcApi { str2 += String(format: "%02X", bArr2[y]) } - let transactionTime = self.getTransactionTime(formatDate: str.subString(from: 0, to: 6), formatTime: str.subString(from: 6, to: 12)) - riwayatCard.setTransactionTime(transactionTime!) + 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 { @@ -266,6 +576,7 @@ public class MandiriEmoneyApi : UnifiedNfcApi { riwayatCard.setTitle(str) if type == -1 { + logMandiri("Skip old log record | reason=unknown_type | len=\(len) | raw=\(str)") return nil } diff --git a/Emoney Info/Classes/api/TapCashApi.swift b/Emoney Info/Classes/api/TapCashApi.swift index 54d6dee..d6e32be 100755 --- a/Emoney Info/Classes/api/TapCashApi.swift +++ b/Emoney Info/Classes/api/TapCashApi.swift @@ -14,6 +14,7 @@ public class TapCashApi : UnifiedNfcApi { var riwayatList: [RiwayatCard] = [] var start = 0 var totalLog = 0 + private var historyRetryCount = 0 public override init() {} @@ -22,15 +23,22 @@ public class TapCashApi : UnifiedNfcApi { if (response.sw1 == 0x90 && response.sw2 == 0x00){ self.emoney.setCardLabel("BNI TapCash") self.tapCashData.setPurseData(response.getData().bytes) - let balance = self.tapCashData.getPurseBalance()?.hexString().hex2decimal() - self.emoney.setBalance(balance!) - self.emoney.setCardNumber(self.tapCashData.getCAN()!.hexString()) - self.totalLog = (self.tapCashData.getTotalRecords()?.hexString().hex2decimal())! + let balance = self.tapCashData.getPurseBalance()?.hexString().hex2decimal() ?? 0 + guard let can = self.tapCashData.getCAN()?.hexString() else { + self.emoney.setTampilRiwayat(false) + self.updateScreen() + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + return + } + self.emoney.setBalance(balance) + self.emoney.setCardNumber(can) + self.totalLog = self.tapCashData.getTotalRecords()?.hexString().hex2decimal() ?? 0 if (self.totalLog > 10){ self.totalLog = 10 } debugLog("total log " + String(self.totalLog)) - self.getHistory(index: 0) + self.historyRetryCount = 0 + self.startHistoryRead() } else { self.emoney.setTampilRiwayat(false) self.updateScreen() @@ -39,29 +47,58 @@ public class TapCashApi : UnifiedNfcApi { } }) } + + private func startHistoryRead() { + start = 0 + riwayatList.removeAll() + getHistory(index: 0) + } + + private func retryHistoryRead(reason: String) { + if historyRetryCount < 1 { + historyRetryCount += 1 + debugLog("Retrying TapCash history read: \(reason)") + startHistoryRead() + } else { + finalizeHistoryRead() + } + } + + private func finalizeHistoryRead() { + self.updateScreen() + self.riwayatList = self.riwayatList.sorted { + ($0.getTransationTime() ?? Date.distantPast) > ($1.getTransationTime() ?? Date.distantPast) + } + + if (self.riwayatList.count > 0){ + self.emoney.setRiwayatList(self.riwayatList) + self.emoney.setTampilRiwayat(true) + } + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } private func getHistory(index : Int){ let st = String(index).leftPad(with: "0", length: 2) + guard let payload = st.stringToBytes() else { + debugLog("Invalid TapCash history index payload: \(st)") + return + } //90 32 03 00 01 00 10 - let TAPCASH_LOG = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0x32, p1Parameter : 0x03, p2Parameter : 0x00, data : Data(_ : st.stringToBytes()!), expectedResponseLength : 16) + let TAPCASH_LOG = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0x32, p1Parameter : 0x03, p2Parameter : 0x00, data : Data(_ : payload), expectedResponseLength : 16) debugLog(TAPCASH_LOG.toHexString()) apduRunner.exchangeApdu(apduCommand: TAPCASH_LOG, completionHandler: {response in if (response.sw1 == 0x90 && response.sw2 == 0x00){ self.addRiwayatTransaksi(data: response.getData().bytes) + } else { + self.retryHistoryRead(reason: "status \(response.sw1)-\(response.sw2) at index \(index)") + return } self.start+=1 if (self.start < (self.totalLog)){ self.getHistory(index: self.start) } else { - self.updateScreen() - self.riwayatList = self.riwayatList.sorted(by: { $0.getTransationTime()?.compare($1.getTransationTime()!) == .orderedDescending }) - - if (self.riwayatList.count > 0){ - self.emoney.setRiwayatList(self.riwayatList) - self.emoney.setTampilRiwayat(true) - } - self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) - self.apduRunner.invalidateSession() + self.finalizeHistoryRead() } }) } @@ -73,6 +110,10 @@ public class TapCashApi : UnifiedNfcApi { } private func addRiwayatTransaksi(data: [UInt8]) { + guard data.count >= 8 else { + debugLog("TapCash history data too short: \(data.count)") + return + } let trxType = Array(data[0..<1]) let trxAmount = Array(data[1..<4]) let trxDateTimes = Array(data[4..<8]) diff --git a/Emoney Info/Classes/api/UnifiedNfcApi.swift b/Emoney Info/Classes/api/UnifiedNfcApi.swift index bd7d412..8dbe8dd 100755 --- a/Emoney Info/Classes/api/UnifiedNfcApi.swift +++ b/Emoney Info/Classes/api/UnifiedNfcApi.swift @@ -21,6 +21,7 @@ extension Dictionary where Key == String, Value == Any { @available(iOS 13.0, *) public class UnifiedNfcApi { var stationMap: [Int: Station] = [:] + var felicaHistoryRetryCount = 0 func parseData() { stationMap = [ @@ -87,7 +88,7 @@ public class UnifiedNfcApi { var apduRunner = ApduRunner() public init() { - langCode = Locale.current.languageCode + langCode = Locale.current.languageCode ?? "en" parseData() } @@ -124,9 +125,16 @@ public class UnifiedNfcApi { public func checkFelicaCard(tag: NFCFeliCaTag){ readFelicaCard(tag: tag) } + + private func logFelica(_ message: String) { + _ = message + // print("[FeliCa] \(message)") + // debugLog("[FeliCa] \(message)") + } func readFelicaCard(tag: NFCFeliCaTag){ let kmt = Emoney() + logFelica("Start read | currentIDm=\(tag.currentIDm.map { String(format: "%02X", $0) }.joined()) | currentSystemCode=\(tag.currentSystemCode.map { String(format: "%02X", $0) }.joined())") let serviceCode = Data([0x0B, 0x30]) let blockList = Data([0x80, 0x00]) @@ -137,21 +145,27 @@ public class UnifiedNfcApi { ) { (status1, status2, blockData, error) in if let error = error { + self.logFelica("Card info read failed | error=\(error.localizedDescription)") self.apduRunner.nfcApi?.stopCheckCard(message: "Gagal membaca: \(error.localizedDescription)") return } // Cek status keberhasilan dari kartu (0x00 0x00 berarti sukses) guard status1 == 0x00 && status2 == 0x00 else { + self.logFelica("Card info read failed | status1=\(status1) | status2=\(status2)") self.apduRunner.nfcApi?.stopCheckCard(message: "Error Status: \(status1) \(status2)") return } for (index, data) in blockData.enumerated() { - debugLog("Data Blok \(index): \(data.map { String(format: "%02X", $0) }.joined())") + let hex = data.map { String(format: "%02X", $0) }.joined() + self.logFelica("Card info block \(index) raw=\(hex)") if let cardNumberString = String(data: data, encoding: .utf8) { + self.logFelica("Card number parsed=\(cardNumberString)") kmt.setCardLabel("KMT") kmt.setCardNumber(cardNumberString) self.readFelicaBalance(tag: tag, kmt: kmt) + } else { + self.logFelica("Card number parse failed for block \(index)") } } } @@ -166,12 +180,18 @@ public class UnifiedNfcApi { blockList: [blockList] ) { (status1, status2, blockData, error) in if let error = error { + self.logFelica("Balance read failed | error=\(error.localizedDescription)") self.apduRunner.nfcApi?.stopCheckCard(message: "Gagal membaca: \(error.localizedDescription)") return } if status1 == 0x00 && status2 == 0x00 { - let cardBalance = [UInt8](blockData[0]) + guard let firstBlock = blockData.first, firstBlock.count >= 4 else { + self.logFelica("Balance block missing or too short") + return + } + self.logFelica("Balance raw=\(firstBlock.map { String(format: "%02X", $0) }.joined())") + let cardBalance = [UInt8](firstBlock) var y: Int = 0 for x in 0..<4 { y += Int(cardBalance[x]) << (x * 8) @@ -180,12 +200,14 @@ public class UnifiedNfcApi { let formatter = NumberFormatter() formatter.locale = Locale(identifier: "id_ID") formatter.numberStyle = .decimal - debugLog("balance") kmt.setBalance(y) if let balance = formatter.string(from: NSNumber(value: y)) { - debugLog("Saldo: \(balance)") // Hasil contoh: "67.305.985" + self.logFelica("Balance parsed=\(y) | formatted=\(balance)") } + self.felicaHistoryRetryCount = 0 self.readFelicaCardHistory(tag: tag, kmt: kmt) + } else { + self.logFelica("Balance read failed | status1=\(status1) | status2=\(status2)") // kmt.setTampilRiwayat(false) // self.apduRunner.callback?.complete(emoney: kmt) // self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) @@ -210,34 +232,38 @@ public class UnifiedNfcApi { ) { (status1, status2, blockData, error) in var riwayatList: [RiwayatCard] = [] if let error = error { - debugLog("Error: \(error.localizedDescription)") + self.logFelica("History read failed | error=\(error.localizedDescription)") + self.retryFelicaHistoryRead(tag: tag, kmt: kmt, reason: "error \(error.localizedDescription)") return } if status1 == 0x00 && status2 == 0x00 { - debugLog("Berhasil membaca 15 blok!") + self.logFelica("History read success | blocks=\(blockData.count)") // blockData akan berisi array of Data, masing-masing 16 byte for (index, data) in blockData.enumerated() { let riwayat = RiwayatCard() var normal = true + let rawHex = data.map { String(format: "%02X", $0) }.joined() + self.logFelica("History block \(index) raw=\(rawHex)") + guard data.count >= 12 else { + self.logFelica("History block \(index) skipped | reason=too_short | count=\(data.count)") + continue + } let subId = data.subdata(in: 8..<10) let uid = self.convert(bytes: [UInt8](subId)) - debugLog("station: \(uid)") - - debugLog("Blok \(index): \(data.map { String(format: "%02X", $0) }.joined())") + self.logFelica("History block \(index) stationId=\(uid)") if (uid == 0){ normal = false } if data.count >= 13 { - let transactionKind = self.felicaTransactionKind(for: [UInt8](data)) + let transactionKind = self.felicaTransactionKind(for: [UInt8](data), stationId: uid) riwayat.setProsesTipe(transactionKind.prosesTipe) riwayat.setTitle(transactionKind.title.localizeString(string: self.langCode!)) - debugLog("signature: \(transactionKind.signature)") - debugLog(transactionKind.logLabel) + self.logFelica("History block \(index) signature=\(transactionKind.signature) | kind=\(transactionKind.logLabel)") } if let station = self.stationMap[uid]{ - debugLog("station", station.name) + self.logFelica("History block \(index) stationName=\(station.name)") riwayat.setLocationName(station.name.uppercased(with: .autoupdatingCurrent)) } // let station = self.stationMap[uid] @@ -247,14 +273,17 @@ public class UnifiedNfcApi { let subData = data.subdata(in: 0..<4) - let date = self.getDate(data: [UInt8](subData)) - riwayat.setTransactionTime(date!) + if let date = self.getDate(data: [UInt8](subData)) { + riwayat.setTransactionTime(date) + } let formatter = DateFormatter() formatter.locale = Locale(identifier: "id_ID") // Format Indonesia formatter.dateFormat = "dd MMMM yyyy, HH:mm" - let dateString = formatter.string(from: date!) - debugLog("Hasil Konversi: \(dateString)") + if let date = riwayat.getTransationTime() { + let dateString = formatter.string(from: date) + self.logFelica("History block \(index) timestamp=\(dateString)") + } let amn = data.subdata(in: 4..<8) let amount = amn.withUnsafeBytes { $0.load(as: Int32.self).bigEndian } @@ -283,11 +312,10 @@ public class UnifiedNfcApi { nformatter.locale = Locale(identifier: "id_ID") nformatter.numberStyle = .decimal if let balance = nformatter.string(from: NSNumber(value: amount)) { - debugLog("amount: \(balance)") // Hasil contoh: "67.305.985" + self.logFelica("History block \(index) amount=\(amount) | formatted=\(balance)") } - debugLog("") } else { - debugLog("RESKA PARKIR") + self.logFelica("History block \(index) mode=RESKA_PARKIR") let stringData = data.map { String(format: "%02X", $0) }.joined() @@ -304,9 +332,9 @@ public class UnifiedNfcApi { let result = outputFormatter.string(from: date) riwayat.setTransactionTime(date) - debugLog(result) // Hasil: 29-01-2026 16:00:44 + self.logFelica("History block \(index) timestamp=\(result)") } else { - debugLog("Format string tidak cocok") + self.logFelica("History block \(index) timestamp_parse_failed") } let amn = data.subdata(in: 8..<12) let amount = amn.withUnsafeBytes { $0.load(as: Int32.self).bigEndian } @@ -318,26 +346,42 @@ public class UnifiedNfcApi { nformatter.locale = Locale(identifier: "id_ID") nformatter.numberStyle = .decimal if let balance = nformatter.string(from: NSNumber(value: amount)) { - debugLog("amount: \(balance)") // Hasil contoh: "67.305.985" + self.logFelica("History block \(index) amount=\(amount) | formatted=\(balance)") } } - debugLog("") + let title = riwayat.getTitle() ?? "-" + let location = riwayat.getLocationName() ?? "-" + self.logFelica("History block \(index) summary | title=\(title) | prosesTipe=\(riwayat.getProsesTipe()) | location=\(location)") riwayatList.append(riwayat) } kmt.setRiwayatList(riwayatList) kmt.setTampilRiwayat(true) + self.logFelica("History parsed total=\(riwayatList.count)") self.apduRunner.callback?.complete(emoney: kmt) self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) self.apduRunner.invalidateSession() } else { - debugLog("Gagal. Status: \(status1), \(status2)") + self.logFelica("History read failed | status1=\(status1) | status2=\(status2)") + self.retryFelicaHistoryRead(tag: tag, kmt: kmt, reason: "status \(status1)-\(status2)") } } } + + private func retryFelicaHistoryRead(tag: NFCFeliCaTag, kmt: Emoney, reason: String) { + if felicaHistoryRetryCount < 1 { + felicaHistoryRetryCount += 1 + logFelica("Retrying history read | reason=\(reason)") + readFelicaCardHistory(tag: tag, kmt: kmt) + } else { + apduRunner.callback?.complete(emoney: kmt) + apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + apduRunner.invalidateSession() + } + } func getDate(data: [UInt8]) -> Date? { // 1. Tentukan TimeZone Jakarta - let timeZone = TimeZone(identifier: "Asia/Jakarta")! + let timeZone = TimeZone(identifier: "Asia/Jakarta") ?? .current var calendar = Calendar(identifier: .gregorian) calendar.timeZone = timeZone @@ -364,7 +408,7 @@ public class UnifiedNfcApi { func convert(bytes: [UInt8]) -> Int { switch bytes.count { case 0: - fatalError("Data kosong") + return 0 case 1: return Int(bytes[0]) case 2: @@ -379,12 +423,17 @@ public class UnifiedNfcApi { } } - private func felicaTransactionKind(for bytes: [UInt8]) -> (prosesTipe: Int, title: String, logLabel: String, signature: String) { + private func felicaTransactionKind(for bytes: [UInt8], stationId: Int) -> (prosesTipe: Int, title: String, logLabel: String, signature: String) { let signatureBytes = Array(bytes[10...12]) let signature = signatureBytes.map { String(format: "%02X", $0) }.joined(separator: " ") + + if stationId == 0 { + return (1, "payment", "Parkir", signature) + } switch signatureBytes { case [0x00, 0x02, 0x00], + [0x02, 0x01, 0x00], [0x03, 0x01, 0x00]: return (0, "topup", "Topup", signature) case [0x01, 0x01, 0x01], @@ -392,14 +441,12 @@ public class UnifiedNfcApi { [0x03, 0x61, 0x01]: return (1, "payment", "Pembayaran", signature) default: - let type = bytes[10] - switch type { + let terminalMarker = bytes[12] + switch terminalMarker { case 0x00: - return (0, "topup", "Topup (fallback)", signature) + return (0, "topup", "Topup (fallback sig[2])", signature) case 0x01: - return (1, "payment", "Pembayaran (fallback)", signature) - case 0x03: - return (1, "payment", "Pembayaran (fallback 0x03)", signature) + return (1, "payment", "Pembayaran (fallback sig[2])", signature) default: return (1, "payment", "Other", signature) } diff --git a/Emoney Info/Classes/api/nfc/ApduResponse.swift b/Emoney Info/Classes/api/nfc/ApduResponse.swift index 0c4c30d..bf7f7b3 100755 --- a/Emoney Info/Classes/api/nfc/ApduResponse.swift +++ b/Emoney Info/Classes/api/nfc/ApduResponse.swift @@ -25,14 +25,14 @@ class ApduResponse { } func getSw1() -> UInt8{ - return sw1! + return sw1 ?? 0 } func getSw2() -> UInt8{ - return sw2! + return sw2 ?? 0 } func getData() -> Data{ - return data! + return data ?? Data() } } diff --git a/Emoney Info/Classes/api/nfc/ApduRunner.swift b/Emoney Info/Classes/api/nfc/ApduRunner.swift index a7f2a7d..a6f9514 100755 --- a/Emoney Info/Classes/api/nfc/ApduRunner.swift +++ b/Emoney Info/Classes/api/nfc/ApduRunner.swift @@ -28,9 +28,9 @@ public class ApduRunner: NSObject, NFCTagReaderSessionDelegate { } func startScan() { - let langCode = Locale.current.languageCode + let langCode = Locale.current.languageCode ?? "en" self.sessionEx = NFCTagReaderSession(pollingOption: [.iso14443, .iso18092], delegate: self) - self.sessionEx?.alertMessage = "scanMessage".localizeString(string: langCode!) + self.sessionEx?.alertMessage = "scanMessage".localizeString(string: langCode) self.sessionEx?.begin() } @@ -47,28 +47,31 @@ public class ApduRunner: NSObject, NFCTagReaderSessionDelegate { debugLog("Nfc Tag???.") return } - if case NFCTag.iso7816(_) = tags.first! { - sessionEx?.connect(to: tags.first!) { [self] (error: Error?) in + guard let firstTag = tags.first else { + return + } + if case NFCTag.iso7816(_) = firstTag { + sessionEx?.connect(to: firstTag) { [self] (error: Error?) in if let err = error { debugLog("Error connecting to Nfc Tag" + err.localizedDescription) return } debugLog("Nfc Tag is connected.") - if (self.callback != nil){ - self.callback!.connected(unifiedNfcApi: self.nfcApi!) + if let callback = self.callback, let nfcApi = self.nfcApi { + callback.connected(unifiedNfcApi: nfcApi) } } - } else if case .feliCa(let feliCaTag) = tags.first! { - sessionEx?.connect(to: tags.first!) { [self] (error: Error?) in + } else if case .feliCa(let feliCaTag) = firstTag { + sessionEx?.connect(to: firstTag) { [self] (error: Error?) in if let err = error { debugLog("Error connecting to Nfc Tag" + err.localizedDescription) return } // felicaTag = feliCaTag debugLog("Felica is connected.") - if (self.callback != nil){ + if let callback = self.callback, let nfcApi = self.nfcApi { debugLog("Felica is connected 2.") - self.callback!.felicaConnected(unifiedNfcApi: self.nfcApi!, tag: feliCaTag) + callback.felicaConnected(unifiedNfcApi: nfcApi, tag: feliCaTag) } // self.sendFelicaCommand(tag: feliCaTag, session: sessionEx!) @@ -88,7 +91,11 @@ public class ApduRunner: NSObject, NFCTagReaderSessionDelegate { func exchangeApdu(apduCommand: NFCISO7816APDU, completionHandler: @escaping CompletionHandler) { - if case let NFCTag.iso7816(nfcTag) = self.sessionEx!.connectedTag! { + guard let connectedTag = self.sessionEx?.connectedTag else { + debugLog("No connected NFC tag available") + return + } + if case let NFCTag.iso7816(nfcTag) = connectedTag { nfcTag.sendCommand(apdu: apduCommand) { (response: Data, sw1: UInt8, sw2: UInt8, error: Error?) in let resp = ApduResponse() diff --git a/Emoney Info/Classes/api/nfc/Emoney.swift b/Emoney Info/Classes/api/nfc/Emoney.swift index 4fa078b..4bdeb8a 100755 --- a/Emoney Info/Classes/api/nfc/Emoney.swift +++ b/Emoney Info/Classes/api/nfc/Emoney.swift @@ -20,15 +20,15 @@ class Emoney { } func getCardNumber() -> String { - return self.cardNumber! + return self.cardNumber ?? "" } func getCardType() -> String { - return self.cardType! + return self.cardType ?? "" } func getRiwayatList() -> [RiwayatCard] { - return self.riwayatList! + return self.riwayatList ?? [] } func isTampilRiwayat() -> Bool { @@ -60,7 +60,7 @@ class Emoney { } func getCardLabel() -> String { - return self.cardLabel! + return self.cardLabel ?? "" } } diff --git a/Emoney Info/Classes/api/utils/BrizziSamHelper.swift b/Emoney Info/Classes/api/utils/BrizziSamHelper.swift index a9cd96a..c08a7db 100755 --- a/Emoney Info/Classes/api/utils/BrizziSamHelper.swift +++ b/Emoney Info/Classes/api/utils/BrizziSamHelper.swift @@ -40,7 +40,7 @@ class BrizziSamHelper { static func mix(_ bArr: [UInt8], _ bArr2: [UInt8]) -> [UInt8] { guard !bArr2.isEmpty else { - fatalError("empty security key") + return bArr } var bArr3 = [UInt8](repeating: 0, count: bArr.count) @@ -82,11 +82,26 @@ class BrizziSamHelper { } func generateSamRandom() -> String { - let sam = BrizziSamHelper.mix(((BrizziSamHelper.encrypt(self.keyCard!, self.random)!).hexEncodedString()).hex2byte().bytes, ("0000000000000000").hex2byte().bytes).hexString().subString(from: 0, to: 16) + guard + let keyCard = self.keyCard, + let encryptedCard = BrizziSamHelper.encrypt(keyCard, self.random)?.hexEncodedString() + else { + return "" + } + let sam = BrizziSamHelper.mix(encryptedCard.hex2byte().bytes, ("0000000000000000").hex2byte().bytes).hexString().subString(from: 0, to: 16) + guard sam.count == 16 else { + return "" + } let sams = sam[sam.index(sam.startIndex, offsetBy: 2).. Data? { diff --git a/Emoney Info/Classes/api/utils/ByteArrayAndHexHelper.swift b/Emoney Info/Classes/api/utils/ByteArrayAndHexHelper.swift index 5d02f8f..678084d 100755 --- a/Emoney Info/Classes/api/utils/ByteArrayAndHexHelper.swift +++ b/Emoney Info/Classes/api/utils/ByteArrayAndHexHelper.swift @@ -25,8 +25,8 @@ public class ByteArrayAndHexHelper { public static func hex(from string: String) -> Data { .init(stride(from: 0, to: string.count, by: 2).map { string[string.index(string.startIndex, offsetBy: $0) ... string.index(string.startIndex, offsetBy: $0 + 1)] - }.map { - UInt8($0, radix: 16)! + }.compactMap { + UInt8($0, radix: 16) }) } diff --git a/Emoney Info/Classes/extensions/String.swift b/Emoney Info/Classes/extensions/String.swift index 9696c5d..9e22b07 100755 --- a/Emoney Info/Classes/extensions/String.swift +++ b/Emoney Info/Classes/extensions/String.swift @@ -25,19 +25,25 @@ extension StringProtocol { } func hex2decimal() -> Int { - Int(self, radix: 16)! + Int(self, radix: 16) ?? 0 } func hex2bin() -> String { - return String(Int(self, radix: 16)!, radix: 2) + guard let value = Int(self, radix: 16) else { + return "" + } + return String(value, radix: 2) } func bin2decimal() -> Int { - return Int(self, radix: 2)! + return Int(self, radix: 2) ?? 0 } func bin2hex() -> String { - return String(Int(self, radix: 2)!, radix: 16) + guard let value = Int(self, radix: 2) else { + return "" + } + return String(value, radix: 16) } func secondComplementsAmount() -> Int{ @@ -180,6 +186,9 @@ extension String { } func subString(from: Int, to: Int) -> String { + guard from >= 0, to >= from, from <= self.count, to <= self.count else { + return "" + } let startIndex = self.index(self.startIndex, offsetBy: from) let endIndex = self.index(self.startIndex, offsetBy: to) return String(self[startIndex.. String { - - let path = Bundle.main.path(forResource: string, ofType: "lproj") - let bundle = Bundle(path: path!) - return NSLocalizedString(self, tableName: nil, bundle: bundle!, + guard + let path = Bundle.main.path(forResource: string, ofType: "lproj"), + let bundle = Bundle(path: path) + else { + return NSLocalizedString(self, comment: "") + } + return NSLocalizedString(self, tableName: nil, bundle: bundle, value: "", comment: "") } } diff --git a/Emoney Info/Classes/smartCard/TapCashData.swift b/Emoney Info/Classes/smartCard/TapCashData.swift index 6f6a6ec..efb5413 100755 --- a/Emoney Info/Classes/smartCard/TapCashData.swift +++ b/Emoney Info/Classes/smartCard/TapCashData.swift @@ -155,6 +155,10 @@ class TapCashData { } private func unpackPurseData(data: [UInt8]) { + guard data.count >= 95 else { + debugLog("TapCash purse data too short: \(data.count)") + return + } version = Array(data[0..<1]) purseStatus = Array(data[1..<2]) purseBalance = Array(data[2..<5]) diff --git a/Emoney Info/Classes/utils/ToastHelper.swift b/Emoney Info/Classes/utils/ToastHelper.swift index 0a4b377..82ac49e 100755 --- a/Emoney Info/Classes/utils/ToastHelper.swift +++ b/Emoney Info/Classes/utils/ToastHelper.swift @@ -23,8 +23,10 @@ class ToastHelper { toastView.alpha = 0 toastView.translatesAutoresizingMaskIntoConstraints = false - let window = UIApplication.shared.delegate?.window! - window?.addSubview(toastView) + guard let window = UIApplication.shared.delegate?.window ?? nil else { + return + } + window.addSubview(toastView) let horizontalCenterContraint: NSLayoutConstraint = NSLayoutConstraint(item: toastView, attribute: .centerX, relatedBy: .equal, toItem: window, attribute: .centerX, multiplier: 1, constant: 0)