Files
Emoney-Info---IOS/Emoney Info/Classes/api/UnifiedNfcApi.swift

525 lines
26 KiB
Swift
Executable File

import Foundation
import CoreNFC
extension CFArray {
func toSwiftArray<T>() -> [T] {
let array = Array<AnyObject>(_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] = [:]
var felicaHistoryRetryCount = 0
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 ?? "en"
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)
}
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])
tag.readWithoutEncryption(
serviceCodeList: [serviceCode],
blockList: [blockList]
) { (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() {
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)")
}
}
}
}
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.logFelica("Balance read failed | error=\(error.localizedDescription)")
self.apduRunner.nfcApi?.stopCheckCard(message: "Gagal membaca: \(error.localizedDescription)")
return
}
if status1 == 0x00 && status2 == 0x00 {
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)
}
let formatter = NumberFormatter()
formatter.locale = Locale(identifier: "id_ID")
formatter.numberStyle = .decimal
kmt.setBalance(y)
if let balance = formatter.string(from: NSNumber(value: y)) {
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!)
// 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 {
self.logFelica("History read failed | error=\(error.localizedDescription)")
self.retryFelicaHistoryRead(tag: tag, kmt: kmt, reason: "error \(error.localizedDescription)")
return
}
if status1 == 0x00 && status2 == 0x00 {
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))
self.logFelica("History block \(index) stationId=\(uid)")
if (uid == 0){
normal = false
}
if data.count >= 13 {
let transactionKind = self.felicaTransactionKind(for: [UInt8](data), stationId: uid)
riwayat.setProsesTipe(transactionKind.prosesTipe)
riwayat.setTitle(transactionKind.title.localizeString(string: self.langCode!))
self.logFelica("History block \(index) signature=\(transactionKind.signature) | kind=\(transactionKind.logLabel)")
}
if let station = self.stationMap[uid]{
self.logFelica("History block \(index) stationName=\(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)
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"
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 }
//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)) {
self.logFelica("History block \(index) amount=\(amount) | formatted=\(balance)")
}
} else {
self.logFelica("History block \(index) mode=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)
self.logFelica("History block \(index) timestamp=\(result)")
} else {
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 }
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)) {
self.logFelica("History block \(index) amount=\(amount) | formatted=\(balance)")
}
}
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 {
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") ?? .current
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:
return 0
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], 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],
[0x01, 0x58, 0x01],
[0x03, 0x61, 0x01]:
return (1, "payment", "Pembayaran", signature)
default:
let terminalMarker = bytes[12]
switch terminalMarker {
case 0x00:
return (0, "topup", "Topup (fallback sig[2])", signature)
case 0x01:
return (1, "payment", "Pembayaran (fallback sig[2])", 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")
}
})
}
}