import Foundation import CoreNFC extension CFArray { func toSwiftArray() -> [T] { let array = Array(_immutableCocoaArray: self) return array.compactMap { $0 as? T } } } extension Dictionary where Key == String, Value == Any { var account: String? { guard let account = self[kSecAttrAccount as String] as? String else { return nil } return account } } @available(iOS 13.0, *) public class UnifiedNfcApi { var stationMap: [Int: Station] = [:] func parseData() { stationMap = [ 0: Station(id: 0, name: "PARKIR RESKA", subName: "PARKIR RESKA", latitude: "0", longitude: "0"), 1: Station(id: 1, name: "Tanah Abang", subName: "Tanah Abang", latitude: "-6.18574476", longitude: "106.8108382"), 67: Station(id: 67, name: "C-Access", subName: "C-Access", latitude: "0", longitude: "0"), 257: Station(id: 257, name: "Bogor", subName: "Bogor", latitude: "-6.59561005", longitude: "106.7904379"), 258: Station(id: 258, name: "Cilebut", subName: "Cilebut", latitude: "-6.53050343", longitude: "106.8005885"), 259: Station(id: 259, name: "Bojonggede", subName: "Bojonggede", latitude: "-6.49326562", longitude: "106.7949173"), 260: Station(id: 260, name: "Citayam", subName: "Citayam", latitude: "-6.44879141", longitude: "106.8024588"), 261: Station(id: 261, name: "Depok", subName: "Depok", latitude: "-6.40493394", longitude: "106.8172447"), 262: Station(id: 262, name: "Depok Baru", subName: "Depok Baru", latitude: "-6.39113047", longitude: "106.821707"), 263: Station(id: 263, name: "Pondok Cina", subName: "Pondok Cina", latitude: "-6.36905168", longitude: "106.8322114"), 264: Station(id: 264, name: "Univ. Indonesia", subName: "Univ. Indonesia", latitude: "-6.36075528", longitude: "106.8317544"), 265: Station(id: 265, name: "Univ. Pancasila", subName: "Univ. Pancasila", latitude: "-6.33894476", longitude: "106.8344241"), 272: Station(id: 272, name: "Lenteng Agung", subName: "Lenteng Agung", latitude: "-6.33065157", longitude: "106.8349938"), 273: Station(id: 273, name: "Tanjung Barat", subName: "Tanjung Barat", latitude: "-6.30780817", longitude: "106.8388513"), 274: Station(id: 274, name: "Pasar Minggu", subName: "Pasar Minggu", latitude: "-6.28440597", longitude: "106.8445384"), 275: Station(id: 275, name: "Pasar Minggu Baru", subName: "Pasar Minggu Baru", latitude: "-6.26278132", longitude: "106.8518598"), 276: Station(id: 276, name: "Duren Kalibata", subName: "Duren Kalibata", latitude: "-6.25534623", longitude: "106.8550195"), 277: Station(id: 277, name: "Cawang", subName: "Cawang", latitude: "-6.24266069", longitude: "106.8588196"), 278: Station(id: 278, name: "Tebet", subName: "Tebet", latitude: "-6.22606896", longitude: "106.8583004"), 279: Station(id: 279, name: "Manggarai", subName: "Manggarai", latitude: "-6.20992352", longitude: "106.8502129"), 280: Station(id: 280, name: "Cikini", subName: "Cikini", latitude: "-6.19856352", longitude: "106.8412599"), 281: Station(id: 281, name: "Gondangdia", subName: "Gondangdia", latitude: "-6.18594019", longitude: "106.8325942"), 288: Station(id: 288, name: "Juanda", subName: "Juanda", latitude: "-6.16672229", longitude: "106.8304674"), 289: Station(id: 289, name: "Sawah Besar", subName: "Sawah Besar", latitude: "-6.16063965", longitude: "106.8276397"), 290: Station(id: 290, name: "Mangga Besar", subName: "Mangga Besar", latitude: "-6.14979667", longitude: "106.8269796"), 291: Station(id: 291, name: "Jayakarta", subName: "Jayakarta", latitude: "-6.14134112", longitude: "106.8230834"), 292: Station(id: 292, name: "Jakarta Kota", subName: "Jakarta Kota", latitude: "-6.13761335", longitude: "106.8146308"), 293: Station(id: 293, name: "Bekasi", subName: "Bekasi", latitude: "-6.23614485", longitude: "106.9994173"), 294: Station(id: 294, name: "Kranji", subName: "Kranji", latitude: "-6.22433352", longitude: "106.9793992"), 295: Station(id: 295, name: "Cakung", subName: "Cakung", latitude: "-6.21929974", longitude: "106.9521357"), 296: Station(id: 296, name: "Klender Baru", subName: "Klender Baru", latitude: "-6.21743543", longitude: "106.9396893"), 297: Station(id: 297, name: "Buaran", subName: "Buaran", latitude: "-6.21615092", longitude: "106.9283069"), 304: Station(id: 304, name: "Klender", subName: "Klender", latitude: "-6.21335877", longitude: "106.8998889"), 305: Station(id: 305, name: "Jatinegara", subName: "Jatinegara", latitude: "-6.21513342", longitude: "106.8703259"), 313: Station(id: 313, name: "Tangerang", subName: "Tangerang", latitude: "-6.17679787", longitude: "106.63272688"), 327: Station(id: 327, name: "Karet", subName: "Karet", latitude: "-6.2008165", longitude: "106.8159002"), 328: Station(id: 328, name: "Sudirman", subName: "Sudirman", latitude: "-6.202438", longitude: "106.8234505"), 329: Station(id: 329, name: "Tanah Abang", subName: "Tanah Abang", latitude: "-6.18574476", longitude: "106.8108382"), 336: Station(id: 336, name: "Palmerah", subName: "Palmerah", latitude: "-6.20740425", longitude: "106.7974463"), 337: Station(id: 337, name: "Kebayoran", subName: "Kebayoran", latitude: "-6.23718958", longitude: "106.782542"), 338: Station(id: 338, name: "Pondok Ranji", subName: "Pondok Ranji", latitude: "-6.27633762", longitude: "106.7449376"), 339: Station(id: 339, name: "Jurang Mangu", subName: "Jurang Mangu", latitude: "-6.28876225", longitude: "106.7291141"), 340: Station(id: 340, name: "Sudimara", subName: "Sudimara", latitude: "-6.29694285", longitude: "106.7127952"), 341: Station(id: 341, name: "Rawabuntu", subName: "Rawabuntu", latitude: "-6.31500105", longitude: "106.6761968"), 342: Station(id: 342, name: "Serpong", subName: "Serpong", latitude: "-6.32004857", longitude: "106.6655717"), 343: Station(id: 343, name: "Cisauk", subName: "Cisauk", latitude: "-6.3249995", longitude: "106.6407467"), 344: Station(id: 344, name: "Cicayur", subName: "Cicayur", latitude: "-6.32951436", longitude: "106.6189624"), 345: Station(id: 345, name: "Parung Panjang", subName: "Parung Panjang", latitude: "-6.34420808", longitude: "106.5698061"), 352: Station(id: 352, name: "Cilejit", subName: "Cilejit", latitude: "-6.35434367", longitude: "106.5097328"), 353: Station(id: 353, name: "Daru", subName: "Daru", latitude: "-6.33800742", longitude: "106.4923913"), 354: Station(id: 354, name: "Tenjo", subName: "Tenjo", latitude: "-6.32725713", longitude: "106.4613542"), 355: Station(id: 355, name: "Tigaraksa", subName: "Tigaraksa", latitude: "-6.32846118", longitude: "106.4347451"), 356: Station(id: 356, name: "Maja", subName: "Maja", latitude: "-6.33230387", longitude: "106.3965692"), 357: Station(id: 357, name: "Citeras", subName: "Citeras", latitude: "-6.33492764", longitude: "106.3327125"), 358: Station(id: 358, name: "Rangkasbitung", subName: "Rangkasbitung", latitude: "-6.3526711", longitude: "106.251502"), 374: Station(id: 374, name: "Bekasi Timur", subName: "Bekasitimur", latitude: "-6.246845", longitude: "107.0181248"), 376: Station(id: 376, name: "Cikarang", subName: "Cikarang", latitude: "-6.2553926", longitude: "107.1451293") ] } var langCode : String? var apduRunner = ApduRunner() public init() { langCode = Locale.current.languageCode parseData() } func setCallback(apduCallback : ApduCallback){ apduRunner.setApduCallback(callback: apduCallback) apduRunner.setUnifiedNfcApi(nfcApi: self) } func setApduRunner(apRunner : ApduRunner){ self.apduRunner = apRunner } public func checkIfNfcSupported() -> Bool { return NFCTagReaderSession.readingAvailable } public func searchCard(){ apduRunner.startScan() } public func checkCard(){ apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BRIZZI_INIT_APDU, completionHandler: {response in if (response.sw1 == 0x91 && response.sw2 == 0x00){ debugLog("brizzi card") let brizzi = BrizziApi() brizzi.setApduRunner(apRunner: self.apduRunner) brizzi.getUid() } else { self.checkNext01() } }) } public func checkFelicaCard(tag: NFCFeliCaTag){ readFelicaCard(tag: tag) } func readFelicaCard(tag: NFCFeliCaTag){ let kmt = Emoney() let serviceCode = Data([0x0B, 0x30]) let blockList = Data([0x80, 0x00]) tag.readWithoutEncryption( serviceCodeList: [serviceCode], blockList: [blockList] ) { (status1, status2, blockData, error) in if let error = error { 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.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())") if let cardNumberString = String(data: data, encoding: .utf8) { kmt.setCardLabel("KMT") kmt.setCardNumber(cardNumberString) self.readFelicaBalance(tag: tag, kmt: kmt) } } } } func readFelicaBalance(tag: NFCFeliCaTag, kmt: Emoney){ let serviceCode = Data([0x17, 0x10]) let blockList = Data([0x80, 0x00]) tag.readWithoutEncryption( serviceCodeList: [serviceCode], blockList: [blockList] ) { (status1, status2, blockData, error) in if let error = error { self.apduRunner.nfcApi?.stopCheckCard(message: "Gagal membaca: \(error.localizedDescription)") return } if status1 == 0x00 && status2 == 0x00 { let cardBalance = [UInt8](blockData[0]) var y: Int = 0 for x in 0..<4 { y += Int(cardBalance[x]) << (x * 8) } 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.readFelicaCardHistory(tag: tag, kmt: kmt) // kmt.setTampilRiwayat(false) // self.apduRunner.callback?.complete(emoney: kmt) // self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) // self.apduRunner.invalidateSession() } } } func readFelicaCardHistory(tag: NFCFeliCaTag, kmt: Emoney){ let serviceCode = Data([0x0F, 0x20]) // 2. Buat daftar 15 blok (Blok 0 sampai 14) secara otomatis var blockList = [Data]() for i in 0..<15 { blockList.append(Data([0x80, UInt8(i)])) } // 3. Panggil fungsi pembacaan tag.readWithoutEncryption( serviceCodeList: [serviceCode], blockList: blockList ) { (status1, status2, blockData, error) in var riwayatList: [RiwayatCard] = [] if let error = error { debugLog("Error: \(error.localizedDescription)") return } if status1 == 0x00 && status2 == 0x00 { debugLog("Berhasil membaca 15 blok!") // blockData akan berisi array of Data, masing-masing 16 byte for (index, data) in blockData.enumerated() { let riwayat = RiwayatCard() var normal = true 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())") if (uid == 0){ normal = false } if data.count >= 13 { let transactionKind = self.felicaTransactionKind(for: [UInt8](data)) riwayat.setProsesTipe(transactionKind.prosesTipe) riwayat.setTitle(transactionKind.title.localizeString(string: self.langCode!)) debugLog("signature: \(transactionKind.signature)") debugLog(transactionKind.logLabel) } if let station = self.stationMap[uid]{ debugLog("station", station.name) riwayat.setLocationName(station.name.uppercased(with: .autoupdatingCurrent)) } // let station = self.stationMap[uid] // print("station", station!) // riwayat.setPlace(self.stationMap[uid]!.name) if (normal){ let subData = data.subdata(in: 0..<4) 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)") let amn = data.subdata(in: 4..<8) let amount = amn.withUnsafeBytes { $0.load(as: Int32.self).bigEndian } //print("Amount: \(amount)") // if (data.count > 10){ // let type = data[10] // print(type) // switch type { // case 0x01: // print("Pembayaran") // case 0x00: // print("Topup") // default: // print("Other") // } // // let subId = data.subdata(in: 8..<10) // // let uid = self.convert(bytes: [UInt8](subId)) // print("station: \(uid)") // } riwayat.setAmount(Int(amount)) let nformatter = NumberFormatter() 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" } debugLog("") } else { debugLog("RESKA PARKIR") let stringData = data.map { String(format: "%02X", $0) }.joined() let inputFormatter = DateFormatter() inputFormatter.dateFormat = "ddMMyyyyHHmmssSS" let finalData = stringData.prefix(16) // 2. Konversi String ke objek Date if let date = inputFormatter.date(from: String(finalData)) { // 3. Inisialisasi Formatter untuk mengubah ke format tujuan let outputFormatter = DateFormatter() outputFormatter.dateFormat = "dd MMM yyyy HH:mm" let result = outputFormatter.string(from: date) riwayat.setTransactionTime(date) debugLog(result) // Hasil: 29-01-2026 16:00:44 } else { debugLog("Format string tidak cocok") } let amn = data.subdata(in: 8..<12) let amount = amn.withUnsafeBytes { $0.load(as: Int32.self).bigEndian } riwayat.setAmount(Int(amount)) //print("Amount: \(amount)") let nformatter = NumberFormatter() 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" } } debugLog("") riwayatList.append(riwayat) } kmt.setRiwayatList(riwayatList) kmt.setTampilRiwayat(true) self.apduRunner.callback?.complete(emoney: kmt) self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) self.apduRunner.invalidateSession() } else { debugLog("Gagal. Status: \(status1), \(status2)") } } } func getDate(data: [UInt8]) -> Date? { // 1. Tentukan TimeZone Jakarta let timeZone = TimeZone(identifier: "Asia/Jakarta")! var calendar = Calendar(identifier: .gregorian) calendar.timeZone = timeZone // 2. Set tanggal dasar (1 Januari 2000, 07:00:00) var components = DateComponents() components.year = 2000 components.month = 1 components.day = 1 components.hour = 7 components.minute = 0 components.second = 0 guard let baseDate = calendar.date(from: components) else { return nil } // 3. Ambil selisih detik dari data byte let secondsToAdd = convert(bytes: data) // 4. Tambahkan detik ke baseDate let finalDate = calendar.date(byAdding: .second, value: secondsToAdd, to: baseDate) return finalDate } func convert(bytes: [UInt8]) -> Int { switch bytes.count { case 0: fatalError("Data kosong") case 1: return Int(bytes[0]) case 2: // Big-endian: (byte[0] << 8) | byte[1] return (Int(bytes[0]) << 8) | Int(bytes[1]) case 3: // (byte[0] << 16) | (byte[1] << 8) | byte[2] return (Int(bytes[0]) << 16) | (Int(bytes[1]) << 8) | Int(bytes[2]) default: // Padanan ByteBuffer.wrap(bArr).getInt() (Big-endian) return Int(bytes.withUnsafeBytes { $0.load(as: Int32.self).bigEndian }) } } private func felicaTransactionKind(for bytes: [UInt8]) -> (prosesTipe: Int, title: String, logLabel: String, signature: String) { let signatureBytes = Array(bytes[10...12]) let signature = signatureBytes.map { String(format: "%02X", $0) }.joined(separator: " ") switch signatureBytes { case [0x00, 0x02, 0x00], [0x03, 0x01, 0x00]: return (0, "topup", "Topup", signature) case [0x01, 0x01, 0x01], [0x01, 0x58, 0x01], [0x03, 0x61, 0x01]: return (1, "payment", "Pembayaran", signature) default: let type = bytes[10] switch type { case 0x00: return (0, "topup", "Topup (fallback)", signature) case 0x01: return (1, "payment", "Pembayaran (fallback)", signature) case 0x03: return (1, "payment", "Pembayaran (fallback 0x03)", signature) default: return (1, "payment", "Other", signature) } } } public func stopCheckCard(message : String){ apduRunner.sessionEx?.invalidate(errorMessage: message) } private func checkNext01(){ apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.FLAZZ_INIT_APDU, completionHandler: {response in if (response.sw1 == 0x90 && response.sw2 == 0x00){ debugLog("flazz card") let flazz = BcaFlazzApi() flazz.setApduRunner(apRunner: self.apduRunner) flazz.checkFlazzCard() } else { self.checkNext02() } }) } private func checkNext02(){ apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.TAPCASH_INIT_APDU, completionHandler: {response in if (response.sw1 == 0x90 && response.sw2 == 0x00){ debugLog("tapcash card") let tapCash = TapCashApi() tapCash.setApduRunner(apRunner: self.apduRunner) tapCash.checkBalance() } else { self.checkNext03() } }) } private func checkNext03(){ apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.EMONEY_INIT_APDU, completionHandler: {response in if (response.sw1 == 0x90 && response.sw2 == 0x00){ debugLog("emoney card") let emoney = MandiriEmoneyApi() emoney.setApduRunner(apRunner: self.apduRunner) emoney.getCardNumber() } else { self.checkNext04() } }) } private func checkNext04(){ apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.JACKCARD_INIT_APDU, completionHandler: {response in if (response.sw1 == 0x90 && response.sw2 == 0x00){ debugLog("jack card") let jack = JackCardApi() jack.setApduRunner(apRunner: self.apduRunner) jack.getBalance(resp: response.getData()) } else { self.checkNext05() } }) } private func checkNext05(){ apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.MEGA_APDU01, completionHandler: {response in if (response.sw1 == 0x90 && response.sw2 == 0x00){ debugLog("megacash") let mega = MegaCashApi() mega.setApduRunner(apRunner: self.apduRunner) mega.getBalance(resp: response.getData()) } else { self.apduRunner.invalidateSession(msg: "Card not supported") } }) } }