From 46f27b9d0f765a16ba8f224224e25ea6e5316546 Mon Sep 17 00:00:00 2001 From: Lakr Date: Sat, 9 May 2026 00:44:43 +0900 Subject: [PATCH 01/10] Fix transit card balance readers --- Example/CENFC/Resources/Info.plist | 2 + .../Cards/Transit/China/TUnionConstants.swift | 1 + .../Cards/Transit/China/TUnionReader.swift | 65 +++++++--- .../Transit/HongKong/OctopusConstants.swift | 20 ++++ .../Transit/HongKong/OctopusReader.swift | 71 +++++++++++ .../Transit/Japan/JapanICConstants.swift | 8 +- .../Cards/Transit/Japan/JapanICReader.swift | 37 +++++- .../Transit/Korea/KSX6924Constants.swift | 8 +- .../Cards/Transit/Korea/KSX6924Reader.swift | 9 ++ .../Cards/Transit/TransitBalance.swift | 9 ++ .../CoreExtendedNFC+Transit.swift | 22 +++- Tests/CoreExtendedNFCTests/JapanICTests.swift | 11 +- Tests/CoreExtendedNFCTests/KSX6924Tests.swift | 7 +- Tests/CoreExtendedNFCTests/OctopusTests.swift | 56 +++++++++ Tests/CoreExtendedNFCTests/TUnionTests.swift | 28 ++++- docs/Transit-Card-Research.md | 112 ++++++++++++++++++ 16 files changed, 422 insertions(+), 44 deletions(-) create mode 100644 Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusConstants.swift create mode 100644 Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift create mode 100644 Tests/CoreExtendedNFCTests/OctopusTests.swift create mode 100644 docs/Transit-Card-Research.md diff --git a/Example/CENFC/Resources/Info.plist b/Example/CENFC/Resources/Info.plist index 6353567..93744af 100644 --- a/Example/CENFC/Resources/Info.plist +++ b/Example/CENFC/Resources/Info.plist @@ -143,6 +143,8 @@ D4100000030001 D4100000140001 D410000029000001 + A000000341000101 + 5041592E535A54 D2760000850100 F049442E43484E A000000812010208 diff --git a/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionConstants.swift b/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionConstants.swift index 2a991c6..677e2fc 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionConstants.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionConstants.swift @@ -25,6 +25,7 @@ enum TUnionConstants { static let GET_BALANCE_CLA: UInt8 = 0x80 static let GET_BALANCE_INS: UInt8 = 0x5C static let GET_BALANCE_P1: UInt8 = 0x00 + static let GET_NEGATIVE_BALANCE_P1: UInt8 = 0x01 static let GET_BALANCE_P2: UInt8 = 0x02 static let GET_BALANCE_LE: UInt8 = 0x04 diff --git a/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionReader.swift b/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionReader.swift index b30b837..ab0a613 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionReader.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionReader.swift @@ -8,9 +8,9 @@ import Foundation /// Balance = bits 1-31 as signed integer (CNY fen, divide by 100 for yuan) /// 3. SELECT file 0x15 + READ BINARY for serial number and validity dates /// -/// ## Limitations -/// - Beijing Yikatong is NOT supported (short AID blocked by iOS CoreNFC) -/// - Only T-Union branded cards with the full AID work on iOS +/// ## iOS Scope +/// - T-Union branded cards with the full AID work on iOS. +/// - Beijing Yikatong uses a short AID outside iOS CoreNFC's selectable AID path. /// /// ## References /// - Metrodroid ChinaTransitData @@ -24,6 +24,7 @@ public struct TUnionReader: Sendable { /// Read T-Union card balance and info. public func readBalance() async throws -> TransitBalance { + NFCLog.info("T-Union balance read start", source: "TUnion") // 1. SELECT T-Union AID let selectResponse = try await transport.sendAPDUWithChaining( CommandAPDU.select(aid: TUnionConstants.tUnionAID) @@ -31,22 +32,13 @@ public struct TUnionReader: Sendable { guard selectResponse.isSuccess else { throw NFCError.unsupportedOperation("T-Union AID not found on this card") } + NFCLog.debug("T-Union SELECT AID ok data=\(selectResponse.data.hexString)", source: "TUnion") - // 2. GET BALANCE - let balanceResponse = try await transport.sendAPDUWithChaining(CommandAPDU( - cla: TUnionConstants.GET_BALANCE_CLA, - ins: TUnionConstants.GET_BALANCE_INS, - p1: TUnionConstants.GET_BALANCE_P1, - p2: TUnionConstants.GET_BALANCE_P2, - le: TUnionConstants.GET_BALANCE_LE - )) - guard balanceResponse.isSuccess, balanceResponse.data.count >= 4 else { - throw NFCError.unexpectedStatusWord(balanceResponse.sw1, balanceResponse.sw2) - } - - let rawValue = Data(balanceResponse.data.prefix(4)).uint32BE - // Balance: bits 1-31 as signed integer (bit 0 is sign flag) - let balanceFen = Int(rawValue >> 1) + // 2. GET BALANCE. CardBal reads balance slot 0 and slot 1, then uses slot0 - slot1. + let balance0 = try await readBalanceSlot(p1: TUnionConstants.GET_BALANCE_P1) + let balance1 = await readOptionalBalanceSlot(p1: TUnionConstants.GET_NEGATIVE_BALANCE_P1) + let balanceFen = balance0 - balance1 + NFCLog.info("T-Union balance read complete balance0=\(balance0) balance1=\(balance1) final=\(balanceFen)", source: "TUnion") // 3. Read file 0x15 for serial and validity let fileInfo = await readFileInfo() @@ -69,6 +61,43 @@ public struct TUnionReader: Sendable { let validUntil: Date? } + private func readBalanceSlot(p1: UInt8) async throws -> Int { + let response = try await transport.sendAPDUWithChaining(Self.balanceAPDU(p1: p1)) + NFCLog.debug( + "T-Union GET BALANCE p1=\(String(format: "%02X", p1)) sw=\(String(format: "%02X%02X", response.sw1, response.sw2)) data=\(response.data.hexString)", + source: "TUnion" + ) + guard response.isSuccess, response.data.count >= 4 else { + throw NFCError.unexpectedStatusWord(response.sw1, response.sw2) + } + + return Self.parseBalanceFen(response.data) + } + + private func readOptionalBalanceSlot(p1: UInt8) async -> Int { + do { + return try await readBalanceSlot(p1: p1) + } catch { + NFCLog.debug("T-Union optional balance slot p1=\(String(format: "%02X", p1)) skipped: \(error.localizedDescription)", source: "TUnion") + return 0 + } + } + + private static func balanceAPDU(p1: UInt8) -> CommandAPDU { + CommandAPDU( + cla: TUnionConstants.GET_BALANCE_CLA, + ins: TUnionConstants.GET_BALANCE_INS, + p1: p1, + p2: TUnionConstants.GET_BALANCE_P2, + le: TUnionConstants.GET_BALANCE_LE + ) + } + + static func parseBalanceFen(_ data: Data) -> Int { + let rawValue = Data(data.prefix(4)).uint32BE + return Int(rawValue >> 1) + } + private func readFileInfo() async -> FileInfo { do { // SELECT file 0x15 diff --git a/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusConstants.swift b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusConstants.swift new file mode 100644 index 0000000..92cc6f3 --- /dev/null +++ b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusConstants.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Constants for Hong Kong Octopus FeliCa transit cards. +/// +/// References: +/// - TRETJapanNFCReader OctopusCardItemType / OctopusCardData +/// - System code 0x8008, balance service 0x0117 +enum OctopusConstants { + /// Octopus FeliCa system code. + static let systemCode = Data([0x80, 0x08]) + + /// Balance service code 0x0117, encoded little-endian for CoreNFC. + static let balanceServiceCode = Data([0x17, 0x01]) + + /// Current Octopus raw offset used by CardBal and older public writeups. + static let currentBalanceRawOffset = 350 + + /// Historical raw offset for very old Octopus records. + static let legacyBalanceRawOffset = 35 +} diff --git a/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift new file mode 100644 index 0000000..8da5543 --- /dev/null +++ b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Reads balance from Hong Kong Octopus FeliCa transit cards. +public struct OctopusReader: Sendable { + let transport: any FeliCaTagTransporting + let balanceOffset: Int + + public init(transport: any FeliCaTagTransporting) { + self.transport = transport + balanceOffset = OctopusConstants.currentBalanceRawOffset + } + + public init( + transport: any FeliCaTagTransporting, + balanceOffset: Int + ) { + self.transport = transport + self.balanceOffset = balanceOffset + } + + public func readBalance() async throws -> TransitBalance { + NFCLog.info("Octopus balance read start idm=\(transport.identifier.hexString)", source: "Octopus") + try validateSystemCode() + + NFCLog.debug("Octopus request service balance=\(OctopusConstants.balanceServiceCode.hexString)", source: "Octopus") + let versions = try await transport.requestService( + nodeCodeList: [OctopusConstants.balanceServiceCode] + ) + NFCLog.debug("Octopus balance service versions=\(versions.map(\.hexString).joined(separator: ","))", source: "Octopus") + guard let version = versions.first, version != Data([0xFF, 0xFF]) else { + throw NFCError.unsupportedOperation("Octopus balance service 0x0117 is unavailable on this card") + } + + let blocks = try await transport.readWithoutEncryption( + serviceCode: OctopusConstants.balanceServiceCode, + blockList: [FeliCaFrame.blockListElement(blockNumber: 0)] + ) + guard let block = blocks.first, block.count >= 4 else { + NFCLog.error("Octopus invalid balance block=\((blocks.first ?? Data()).hexString)", source: "Octopus") + throw NFCError.invalidResponse(blocks.first ?? Data()) + } + + let rawValue = Int(Data(block.prefix(4)).uint32BE) + let balanceCents = Self.balanceCents(rawValue: rawValue, offset: balanceOffset) + NFCLog.debug( + "Octopus balance block=\(block.hexString) raw=\(rawValue) offset=\(balanceOffset) cents=\(balanceCents)", + source: "Octopus" + ) + NFCLog.info("Octopus balance read complete balanceCents=\(balanceCents)", source: "Octopus") + + return TransitBalance( + serialNumber: transport.identifier.hexString, + balanceRaw: balanceCents, + currencyCode: "HKD", + cardName: "Octopus" + ) + } + + static func balanceCents(rawValue: Int, offset: Int = OctopusConstants.currentBalanceRawOffset) -> Int { + (rawValue - offset) * 10 + } + + private func validateSystemCode() throws { + NFCLog.debug("Octopus system code=\(transport.systemCode.hexString) expected=\(OctopusConstants.systemCode.hexString)", source: "Octopus") + guard transport.systemCode == OctopusConstants.systemCode else { + throw NFCError.unsupportedOperation( + "Expected FeliCa system code 0x8008 (Octopus), got \(transport.systemCode.hexString)" + ) + } + } +} diff --git a/Sources/CoreExtendedNFC/Cards/Transit/Japan/JapanICConstants.swift b/Sources/CoreExtendedNFC/Cards/Transit/Japan/JapanICConstants.swift index 5729c20..3e4e296 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/Japan/JapanICConstants.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/Japan/JapanICConstants.swift @@ -19,13 +19,13 @@ enum JapanICConstants { /// Transaction history service (cyclic read-only, up to 20 blocks). static let historyServiceCode = Data([0x0F, 0x09]) // 0x090F - /// Maximum history blocks to read per session (safe limit). - static let maxHistoryBlocks = 10 + /// Maximum transaction history blocks stored by common CJRC cards. + static let maxHistoryBlocks = 20 // MARK: - Balance Block Layout (16 bytes) - /// Byte offset of the 2-byte LE balance within a balance block. - static let balanceOffset = 0x0A + /// Byte offset of the 2-byte LE balance within the 0x008B balance block. + static let balanceOffset = 0x0B // MARK: - History Block Layout (16 bytes) diff --git a/Sources/CoreExtendedNFC/Cards/Transit/Japan/JapanICReader.swift b/Sources/CoreExtendedNFC/Cards/Transit/Japan/JapanICReader.swift index c3e9f8f..fa61918 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/Japan/JapanICReader.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/Japan/JapanICReader.swift @@ -20,8 +20,10 @@ public struct JapanICReader: Sendable { /// Read balance only (fast — single block read). public func readBalance() async throws -> TransitBalance { + NFCLog.info("Japan IC balance read start idm=\(transport.identifier.hexString)", source: "JapanIC") try validateSystemCode() let balance = try await readRawBalance() + NFCLog.info("Japan IC balance read complete balance=\(balance) JPY", source: "JapanIC") return TransitBalance( serialNumber: transport.identifier.hexString, balanceRaw: balance, @@ -32,9 +34,11 @@ public struct JapanICReader: Sendable { /// Read balance + transaction history. public func readBalanceAndHistory() async throws -> TransitBalance { + NFCLog.info("Japan IC balance/history read start idm=\(transport.identifier.hexString)", source: "JapanIC") try validateSystemCode() let balance = try await readRawBalance() let transactions = await readHistory() + NFCLog.info("Japan IC balance/history read complete balance=\(balance) JPY transactions=\(transactions.count)", source: "JapanIC") return TransitBalance( serialNumber: transport.identifier.hexString, balanceRaw: balance, @@ -47,6 +51,7 @@ public struct JapanICReader: Sendable { // MARK: - Private private func validateSystemCode() throws { + NFCLog.debug("Japan IC system code=\(transport.systemCode.hexString) expected=\(JapanICConstants.systemCode.hexString)", source: "JapanIC") guard transport.systemCode == JapanICConstants.systemCode else { throw NFCError.unsupportedOperation( "Expected FeliCa system code 0x0003 (CJRC), got \(transport.systemCode.hexString)" @@ -56,9 +61,11 @@ public struct JapanICReader: Sendable { private func readRawBalance() async throws -> Int { // Verify balance service exists + NFCLog.debug("Japan IC request service balance=\(JapanICConstants.balanceServiceCode.hexString)", source: "JapanIC") let versions = try await transport.requestService( nodeCodeList: [JapanICConstants.balanceServiceCode] ) + NFCLog.debug("Japan IC balance service versions=\(versions.map(\.hexString).joined(separator: ","))", source: "JapanIC") guard let version = versions.first, version != Data([0xFF, 0xFF]) else { @@ -70,15 +77,25 @@ public struct JapanICReader: Sendable { serviceCode: JapanICConstants.balanceServiceCode, blockList: [FeliCaFrame.blockListElement(blockNumber: 0)] ) - guard let block = blocks.first, block.count >= 12 else { + guard let block = blocks.first, + block.count >= JapanICConstants.balanceOffset + 2 + else { + NFCLog.error("Japan IC invalid balance block=\((blocks.first ?? Data()).hexString)", source: "JapanIC") throw NFCError.invalidResponse(blocks.first ?? Data()) } - return Int(Data(block[JapanICConstants.balanceOffset ..< JapanICConstants.balanceOffset + 2]).uint16LE) + let balanceBytes = Data(block[JapanICConstants.balanceOffset ..< JapanICConstants.balanceOffset + 2]) + let balance = Int(balanceBytes.uint16LE) + NFCLog.debug( + "Japan IC balance block=\(block.hexString) offset=\(JapanICConstants.balanceOffset) bytes=\(balanceBytes.hexString) value=\(balance)", + source: "JapanIC" + ) + return balance } private func readHistory() async -> [TransitTransaction] { var transactions: [TransitTransaction] = [] + NFCLog.debug("Japan IC history read start service=\(JapanICConstants.historyServiceCode.hexString) maxBlocks=\(JapanICConstants.maxHistoryBlocks)", source: "JapanIC") // Read history blocks one at a time; stop on first failure for i in 0 ..< JapanICConstants.maxHistoryBlocks { @@ -87,18 +104,30 @@ public struct JapanICReader: Sendable { serviceCode: JapanICConstants.historyServiceCode, blockList: [FeliCaFrame.blockListElement(blockNumber: UInt16(i))] ) - guard let block = blocks.first, block.count == 16 else { break } + guard let block = blocks.first, block.count == 16 else { + NFCLog.debug("Japan IC history block #\(i) invalid=\((blocks.first ?? Data()).hexString)", source: "JapanIC") + break + } + NFCLog.debug("Japan IC history block #\(i)=\(block.hexString)", source: "JapanIC") // Skip empty blocks (all zeros) - if block.allSatisfy({ $0 == 0 }) { continue } + if block.allSatisfy({ $0 == 0 }) { + NFCLog.debug("Japan IC history block #\(i) empty", source: "JapanIC") + continue + } if let tx = parseHistoryBlock(block) { + NFCLog.debug("Japan IC history block #\(i) parsed type=\(tx.type.rawValue) balanceAfter=\(tx.balanceAfter) entry=\(tx.entryStation ?? "") exit=\(tx.exitStation ?? "")", source: "JapanIC") transactions.append(tx) + } else { + NFCLog.debug("Japan IC history block #\(i) skipped by parser", source: "JapanIC") } } catch { + NFCLog.debug("Japan IC history block #\(i) read stopped: \(error.localizedDescription)", source: "JapanIC") break } } + NFCLog.debug("Japan IC history read complete transactions=\(transactions.count)", source: "JapanIC") return transactions } diff --git a/Sources/CoreExtendedNFC/Cards/Transit/Korea/KSX6924Constants.swift b/Sources/CoreExtendedNFC/Cards/Transit/Korea/KSX6924Constants.swift index 5cf4b6d..1255feb 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/Korea/KSX6924Constants.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/Korea/KSX6924Constants.swift @@ -11,13 +11,17 @@ enum KSX6924Constants { static let tMoneyAID = Data([0xD4, 0x10, 0x00, 0x00, 0x03, 0x00, 0x01]) static let cashbeeAID = Data([0xD4, 0x10, 0x00, 0x00, 0x14, 0x00, 0x01]) - static let moibaAID = Data([0xD4, 0x10, 0x00, 0x00, 0x30, 0x00, 0x01]) + static let snapperAID = Data([0xD4, 0x10, 0x00, 0x00, 0x30, 0x00, 0x01]) static let kcashAID = Data([0xD4, 0x10, 0x65, 0x09, 0x90, 0x00, 0x20]) + static let hyundaiAID = Data([0xA0, 0x00, 0x00, 0x04, 0x52, 0x00, 0x01]) + static let ebAID = Data([0xD4, 0x10, 0x00, 0x00, 0x29, 0x00, 0x00, 0x01]) static let allAIDs: [(name: String, aid: Data)] = [ + ("Hyundai Capital Services", hyundaiAID), ("T-Money", tMoneyAID), ("Cashbee", cashbeeAID), - ("MOIBA", moibaAID), + ("Snapper / MOIBA", snapperAID), + ("EB Card", ebAID), ("K-Cash", kcashAID), ] diff --git a/Sources/CoreExtendedNFC/Cards/Transit/Korea/KSX6924Reader.swift b/Sources/CoreExtendedNFC/Cards/Transit/Korea/KSX6924Reader.swift index 8402f4f..11b78b7 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/Korea/KSX6924Reader.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/Korea/KSX6924Reader.swift @@ -21,9 +21,11 @@ public struct KSX6924Reader: Sendable { /// Try all known KS X 6924 AIDs and read balance from the first match. public func readBalance() async throws -> TransitBalance { + NFCLog.info("KSX6924 balance read start", source: "KSX6924") let (cardName, fciData) = try await selectCard() let purseInfo = parsePurseInfo(fciData) let balance = try await readRawBalance() + NFCLog.info("KSX6924 balance read complete card=\(cardName) balance=\(balance)", source: "KSX6924") return TransitBalance( serialNumber: purseInfo.serial, @@ -37,10 +39,12 @@ public struct KSX6924Reader: Sendable { /// Read balance + transaction records. public func readBalanceAndHistory() async throws -> TransitBalance { + NFCLog.info("KSX6924 balance/history read start", source: "KSX6924") let (cardName, fciData) = try await selectCard() let purseInfo = parsePurseInfo(fciData) let balance = try await readRawBalance() let transactions = await readRecords() + NFCLog.info("KSX6924 balance/history read complete card=\(cardName) balance=\(balance) transactions=\(transactions.count)", source: "KSX6924") return TransitBalance( serialNumber: purseInfo.serial, @@ -58,10 +62,13 @@ public struct KSX6924Reader: Sendable { /// Try each known AID until one succeeds. Returns (cardName, fciResponseData). private func selectCard() async throws -> (String, Data) { for (name, aid) in KSX6924Constants.allAIDs { + NFCLog.debug("KSX6924 SELECT AID \(aid.hexString) (\(name))", source: "KSX6924") let response = try await transport.sendAPDUWithChaining(CommandAPDU.select(aid: aid)) if response.isSuccess { + NFCLog.debug("KSX6924 SELECT AID success card=\(name) data=\(response.data.hexString)", source: "KSX6924") return (name, response.data) } + NFCLog.debug("KSX6924 SELECT AID rejected card=\(name) sw=\(String(format: "%02X%02X", response.sw1, response.sw2))", source: "KSX6924") } throw NFCError.unsupportedOperation("No supported KS X 6924 AID found on this card") } @@ -75,6 +82,7 @@ public struct KSX6924Reader: Sendable { le: KSX6924Constants.BALANCE_RESP_LEN ) let response = try await transport.sendAPDUWithChaining(apdu) + NFCLog.debug("KSX6924 GET BALANCE sw=\(String(format: "%02X%02X", response.sw1, response.sw2)) data=\(response.data.hexString)", source: "KSX6924") guard response.isSuccess, response.data.count >= 4 else { throw NFCError.unexpectedStatusWord(response.sw1, response.sw2) } @@ -95,6 +103,7 @@ public struct KSX6924Reader: Sendable { ) let response = try await transport.sendAPDUWithChaining(apdu) guard response.isSuccess, response.data.count >= 14 else { break } + NFCLog.debug("KSX6924 record #\(i)=\(response.data.hexString)", source: "KSX6924") if let tx = Self.parseRecord(response.data) { transactions.append(tx) diff --git a/Sources/CoreExtendedNFC/Cards/Transit/TransitBalance.swift b/Sources/CoreExtendedNFC/Cards/Transit/TransitBalance.swift index 39ab87f..31b747a 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/TransitBalance.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/TransitBalance.swift @@ -45,6 +45,15 @@ public struct TransitBalance: Sendable, Equatable, Codable { case "CNY": let yuan = Double(balanceRaw) / 100.0 return String(format: "¥%.2f", yuan) + case "HKD": + let dollars = Double(balanceRaw) / 100.0 + return String(format: "HK$%.2f", dollars) + case "SGD": + let dollars = Double(balanceRaw) / 100.0 + return String(format: "S$%.2f", dollars) + case "NZD": + let dollars = Double(balanceRaw) / 100.0 + return String(format: "NZ$%.2f", dollars) default: return "\(balanceRaw) \(currencyCode)" } diff --git a/Sources/CoreExtendedNFC/CoreExtendedNFC+Transit.swift b/Sources/CoreExtendedNFC/CoreExtendedNFC+Transit.swift index 1dc0ac9..22332a6 100644 --- a/Sources/CoreExtendedNFC/CoreExtendedNFC+Transit.swift +++ b/Sources/CoreExtendedNFC/CoreExtendedNFC+Transit.swift @@ -2,22 +2,27 @@ import Foundation public extension CoreExtendedNFC { /// Read transit card balance from a connected tag. - /// Automatically detects Japan IC (FeliCa), T-Money/Cashbee (KS X 6924), or T-Union cards. + /// Automatically detects Japan IC (FeliCa), Octopus, T-Money/Cashbee/Snapper (KS X 6924), or T-Union cards. /// /// Detection order: /// 1. FeliCa with system code 0x0003 → Japan IC - /// 2. ISO 7816 → try KS X 6924 AIDs (T-Money, Cashbee, MOIBA, K-Cash) + /// 2. ISO 7816 → try KS X 6924 AIDs (T-Money, Cashbee, Snapper/MOIBA, K-Cash) /// 3. ISO 7816 → try T-Union AID static func readTransitBalance( info: CardInfo, transport: any NFCTagTransport ) async throws -> TransitBalance { - // Japan FeliCa IC cards if info.type.family == .felica { guard let felicaTransport = transport as? any FeliCaTagTransporting else { throw NFCError.unsupportedOperation("FeliCa transit reading requires a FeliCa transport") } - return try await readJapanICBalance(transport: felicaTransport) + if felicaTransport.systemCode == JapanICConstants.systemCode { + return try await readJapanICBalance(transport: felicaTransport) + } + if felicaTransport.systemCode == OctopusConstants.systemCode { + return try await readOctopusBalance(transport: felicaTransport) + } + throw NFCError.unsupportedOperation("Unsupported FeliCa transit system code \(felicaTransport.systemCode.hexString)") } // ISO 7816 cards: try Korea then China @@ -55,6 +60,13 @@ public extension CoreExtendedNFC { try await JapanICReader(transport: transport).readBalanceAndHistory() } + /// Read Hong Kong Octopus balance. + static func readOctopusBalance( + transport: any FeliCaTagTransporting + ) async throws -> TransitBalance { + try await OctopusReader(transport: transport).readBalance() + } + /// Read Korea T-Money/Cashbee balance + history (KS X 6924). static func readKoreaTransitBalance( transport: any ISO7816TagTransporting @@ -64,7 +76,7 @@ public extension CoreExtendedNFC { /// Read China T-Union balance. /// - /// Note: Beijing Yikatong is not supported on iOS (short AID restriction). + /// Note: Beijing Yikatong uses a short AID outside iOS CoreNFC's selectable AID path. static func readChinaTransitBalance( transport: any ISO7816TagTransporting ) async throws -> TransitBalance { diff --git a/Tests/CoreExtendedNFCTests/JapanICTests.swift b/Tests/CoreExtendedNFCTests/JapanICTests.swift index c1d884e..7265042 100644 --- a/Tests/CoreExtendedNFCTests/JapanICTests.swift +++ b/Tests/CoreExtendedNFCTests/JapanICTests.swift @@ -14,10 +14,10 @@ struct JapanICTests { @Test func `Read balance from Japan IC card`() async throws { - // Build a 16-byte balance block with 1,234 yen at offset 0x0A (LE) + // Build a 16-byte balance block with 1,234 yen at offset 0x0B (LE) var balanceBlock = Data(repeating: 0x00, count: 16) - balanceBlock[0x0A] = 0xD2 // 1234 & 0xFF - balanceBlock[0x0B] = 0x04 // 1234 >> 8 + balanceBlock[0x0B] = 0xD2 // 1234 & 0xFF + balanceBlock[0x0C] = 0x04 // 1234 >> 8 let transport = MockFeliCaServiceTransport( serviceVersions: [Data([0x8B, 0x00]): Data([0x00, 0x10])], @@ -38,9 +38,8 @@ struct JapanICTests { @Test func `Read balance and history`() async throws { var balanceBlock = Data(repeating: 0x00, count: 16) - balanceBlock[0x0A] = 0xE8 // 500 & 0xFF = 0xF4... wait, 500 = 0x01F4 - balanceBlock[0x0A] = 0xF4 - balanceBlock[0x0B] = 0x01 + balanceBlock[0x0B] = 0xF4 + balanceBlock[0x0C] = 0x01 // History block: usage=0x01 (trip), date=2024-03-15, balance=500 var historyBlock = Data(repeating: 0x00, count: 16) diff --git a/Tests/CoreExtendedNFCTests/KSX6924Tests.swift b/Tests/CoreExtendedNFCTests/KSX6924Tests.swift index 49c3e15..7e1cb3e 100644 --- a/Tests/CoreExtendedNFCTests/KSX6924Tests.swift +++ b/Tests/CoreExtendedNFCTests/KSX6924Tests.swift @@ -18,6 +18,7 @@ struct KSX6924Tests { // SELECT succeeds with FCI containing purse info let fci = buildFCI(serial: "1234567890ABCDEF", issueDate: "20200101", expiryDate: "20301231") transport.apduResponses = [ + ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), // Hyundai SELECT fails ResponseAPDU(data: fci, sw1: 0x90, sw2: 0x00), // SELECT ResponseAPDU(data: Data([0x00, 0x00, 0x27, 0x10]), sw1: 0x90, sw2: 0x00), // GET BALANCE: 10000 KRW ] @@ -36,6 +37,7 @@ struct KSX6924Tests { let transport = MockTransport() let fci = buildFCI(serial: "AABBCCDD11223344", issueDate: "20210601", expiryDate: "20310601") transport.apduResponses = [ + ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), // Hyundai SELECT fails ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), // T-Money SELECT fails ResponseAPDU(data: fci, sw1: 0x90, sw2: 0x00), // Cashbee SELECT succeeds ResponseAPDU(data: Data([0x00, 0x00, 0x13, 0x88]), sw1: 0x90, sw2: 0x00), // GET BALANCE: 5000 KRW @@ -51,12 +53,14 @@ struct KSX6924Tests { @Test func `All AIDs fail throws error`() async { let transport = MockTransport() - // All 4 SELECTs fail + // All SELECTs fail transport.apduResponses = [ ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), + ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), + ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), ] let reader = KSX6924Reader(transport: transport) @@ -72,6 +76,7 @@ struct KSX6924Tests { let transport = MockTransport() let fci = buildMinimalFCI() transport.apduResponses = [ + ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), // Hyundai SELECT fails ResponseAPDU(data: fci, sw1: 0x90, sw2: 0x00), // SELECT ResponseAPDU(data: Data([0x00, 0x01, 0x86, 0xA0]), sw1: 0x90, sw2: 0x00), // 100000 KRW ] diff --git a/Tests/CoreExtendedNFCTests/OctopusTests.swift b/Tests/CoreExtendedNFCTests/OctopusTests.swift new file mode 100644 index 0000000..c9c6487 --- /dev/null +++ b/Tests/CoreExtendedNFCTests/OctopusTests.swift @@ -0,0 +1,56 @@ +// Hong Kong Octopus FeliCa transit card tests. +// +// ## References +// - System code: 0x8008 +// - Balance service: 0x0117, encoded as 17 01 for CoreNFC +// - Balance block: first 4 bytes big-endian raw value +// - Current balance: (raw - 350) / 10 HKD +@testable import CoreExtendedNFC +import Foundation +import Testing + +struct OctopusTests { + @Test + func `Read Octopus balance`() async throws { + var block = Data(repeating: 0x00, count: 16) + block[0] = 0x00 + block[1] = 0x00 + block[2] = 0x12 + block[3] = 0x0B // 4619 raw -> (4619 - 350) * 10 = 42690 cents + + let transport = MockFeliCaServiceTransport( + serviceVersions: [Data([0x17, 0x01]): Data([0x00, 0x10])], + serviceBlocks: [Data([0x17, 0x01]): [block]], + systemCode: Data([0x80, 0x08]) + ) + + let result = try await OctopusReader(transport: transport).readBalance() + + #expect(result.balanceRaw == 42690) + #expect(result.currencyCode == "HKD") + #expect(result.cardName == "Octopus") + #expect(result.formattedBalance == "HK$426.90") + } + + @Test + func `Octopus legacy offset remains available`() { + let cents = OctopusReader.balanceCents( + rawValue: 4557, + offset: OctopusConstants.legacyBalanceRawOffset + ) + + #expect(cents == 45220) + } + + @Test + func `Octopus system code mismatch throws error`() async { + let transport = MockFeliCaServiceTransport( + serviceVersions: [:], + systemCode: Data([0x00, 0x03]) + ) + + await #expect(throws: NFCError.self) { + _ = try await OctopusReader(transport: transport).readBalance() + } + } +} diff --git a/Tests/CoreExtendedNFCTests/TUnionTests.swift b/Tests/CoreExtendedNFCTests/TUnionTests.swift index dcfd7e2..ab2ca29 100644 --- a/Tests/CoreExtendedNFCTests/TUnionTests.swift +++ b/Tests/CoreExtendedNFCTests/TUnionTests.swift @@ -18,7 +18,8 @@ struct TUnionTests { // Balance = 5000 fen (50 yuan). Shifted left by 1: 5000 << 1 = 10000 = 0x00002710 transport.apduResponses = [ ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), // SELECT AID - ResponseAPDU(data: Data([0x00, 0x00, 0x27, 0x10]), sw1: 0x90, sw2: 0x00), // GET BALANCE + ResponseAPDU(data: Data([0x00, 0x00, 0x27, 0x10]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 0 + ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0x00]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 1 ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), // SELECT file 0x15 ResponseAPDU(data: buildFile15Data(), sw1: 0x90, sw2: 0x00), // READ BINARY ] @@ -53,7 +54,8 @@ struct TUnionTests { // 12345 fen → shifted: 12345 << 1 = 24690 = 0x00006072 transport.apduResponses = [ ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), // SELECT - ResponseAPDU(data: Data([0x00, 0x00, 0x60, 0x72]), sw1: 0x90, sw2: 0x00), // GET BALANCE + ResponseAPDU(data: Data([0x00, 0x00, 0x60, 0x72]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 0 + ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0x00]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 1 ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), // SELECT file fails (no file info) ] @@ -68,7 +70,8 @@ struct TUnionTests { let transport = MockTransport() transport.apduResponses = [ ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), // SELECT - ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0x00]), sw1: 0x90, sw2: 0x00), // GET BALANCE + ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0x00]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 0 + ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0x00]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 1 ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), // no file info ] @@ -111,7 +114,8 @@ struct TUnionTests { let transport = MockTransport() transport.apduResponses = [ ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), // SELECT AID - ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0x02]), sw1: 0x90, sw2: 0x00), // GET BALANCE: 1 fen + ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0x02]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 0: 1 fen + ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0x00]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 1 ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), // SELECT file ResponseAPDU(data: buildFile15Data(serial: "31234567890123456789"), sw1: 0x90, sw2: 0x00), ] @@ -124,6 +128,22 @@ struct TUnionTests { #expect(result.serialNumber == "1234567890123456789") } + @Test + func `Final balance subtracts slot 1 from slot 0`() async throws { + let transport = MockTransport() + transport.apduResponses = [ + ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), + ResponseAPDU(data: Data([0x00, 0x00, 0x27, 0x10]), sw1: 0x90, sw2: 0x00), // 5000 + ResponseAPDU(data: Data([0x00, 0x00, 0x03, 0xE8]), sw1: 0x90, sw2: 0x00), // 500 + ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), + ] + + let reader = TUnionReader(transport: transport) + let result = try await reader.readBalance() + + #expect(result.balanceRaw == 4500) + } + // MARK: - Helpers private func buildFile15Data(serial: String = "30112233445566778899") -> Data { diff --git a/docs/Transit-Card-Research.md b/docs/Transit-Card-Research.md new file mode 100644 index 0000000..2da7cab --- /dev/null +++ b/docs/Transit-Card-Research.md @@ -0,0 +1,112 @@ +# Transit Card Research + +Research date: 2026-05-09 + +This note captures the CardBal IDA findings, public implementation checks, and the CoreExtendedNFC changes made for the current transit-card balance work. + +## CardBal IDA Source + +CardBal was inspected through the local IDA MCP instance: + +- IDB: `/Users/qaq/Desktop/cardbal.unfair-iossim.app/CardBal.i64` +- MCP endpoint: `http://127.0.0.1:13337/mcp` +- Binary: `CardBal` + +Useful CardBal addresses: + +| Area | Address | Finding | +| --- | ---: | --- | +| FeliCa profile table | `0x1002DD064` | Builds FeliCa transit-card profiles. | +| Japan Transit IC balance | `0x1002DD7E4` | Parses service `008B` balance from bytes 11-12, little-endian. | +| Octopus balance | `0x1002DDC08` | Parses service `0117` block 0 first four bytes, big-endian raw value. | +| Octopus offset | `0x1001A1AEC` | Returns raw offset `350` from 2010-12-01, legacy offset `35`. | +| Japan brand mapping | `0x1002DB5D8` | Maps issuer/operator hints such as `JE` to Suica, `JW` to ICOCA, `NR` to Nimoca. | +| Japan activity view | `0x100195994` | Shows `108F` history layout details. | +| T-Union AID gate | `0x1001D6A98` | Checks initial selected AID `A000000632010105`. | +| T-Union primary purse | `0x1001D7318` | Sends `80 5C 00 02`, `Le=04`. | +| T-Union negative purse | `0x1001D794C` | Sends `80 5C 01 02`, `Le=04`. | +| KSX6924 AID set | `0x1001726B8` | Includes Hyundai, T-Money, Cashbee, EB Card, Snapper/MOIBA, and K-Cash AIDs. | + +## Implemented Fixes + +### Japan IC, ICOCA, Nimoca + +CardBal confirms the standard Japan Transit IC balance path: + +- FeliCa system code: `0003` +- Balance service: `008B`, encoded for CoreNFC as `8B 00` +- Balance bytes: block bytes 11-12 +- Endianness: little-endian +- Unit: JPY + +CoreExtendedNFC now reads the balance at offset `0x0B`, matching CardBal. The history read limit is also increased to 20 blocks. Extra logs now include system code, service request versions, raw balance block, raw history blocks, and parsed balance. + +### Hong Kong Octopus + +CardBal and TRETJapanNFCReader agree on the card path: + +- FeliCa system code: `8008` +- Balance service: `0117`, encoded for CoreNFC as `17 01` +- Balance block: block 0 +- Raw value: first four bytes, big-endian +- Current offset: `350` +- Legacy offset: `35` +- Formula in HKD: `(raw - offset) / 10` + +CoreExtendedNFC now adds `OctopusReader`, dispatches FeliCa system code `8008` to that reader, formats HKD balances, and logs raw block details. The unified `TransitBalance.balanceRaw` value stores cents, so the code uses `(raw - offset) * 10`. + +### China T-Union, Shenzhen Tong, Nanjing + +CardBal confirms the T-Union balance flow: + +- AID: `A000000632010105` +- Primary purse APDU: `80 5C 00 02`, `Le=04` +- Negative purse APDU: `80 5C 01 02`, `Le=04` +- Final balance: primary purse minus negative purse +- Unit: CNY fen + +CoreExtendedNFC now reads both purse slots and logs SELECT, each purse APDU response, and the final parsed value. Existing Shenzhen and Nanjing T-Union cards should follow this same AID path when the card exposes the national transport application. + +The older Shenzhen Tong AID `5041592E535A54` has been added to the sample app polling identifiers for discovery and logging. Confirmed balance support uses the T-Union AID path above. + +### Snapper / KSX6924 + +CardBal includes the KSX6924-family AID set: + +- Hyundai: `A0000004520001` +- T-Money: `D4100000030001` +- Cashbee: `D4100000140001` +- EB Card: `D410000029000001` +- Snapper / MOIBA: `D4100000300001` +- K-Cash: `D4106509900020` + +CoreExtendedNFC now tries these AIDs in that order and logs SELECT outcomes, balance APDU responses, and record reads. The balance APDU remains the KSX6924 command `90 4C 00 00`, `Le=04`. + +## Researched Cards + +| Card | Protocol reality | Current library handling | +| --- | --- | --- | +| EasyCard | Classic EasyCard is MIFARE Classic. Crypto1 authenticated reads sit outside iOS CoreNFC's public API. | Identification/logging path only. | +| Octopus | FeliCa system `8008`, service `0117`. | Implemented balance reader. | +| T-Union / Shenzhen / Nanjing | ISO 7816 AID `A000000632010105`, dual-purse balance. | Implemented dual-purse balance. | +| Singpass | Singpass is an identity/verification product. Singapore passport reading is eMRTD; CEPAS/EZ-Link uses separate transit-card AIDs. | Existing passport module covers eMRTD. CEPAS AID discovery was added for logs. | +| ICOCA | Japan Transit IC on FeliCa system `0003`. | Implemented via Japan IC reader with corrected offset. | +| Nimoca | Japan Transit IC on FeliCa system `0003`. | Implemented via Japan IC reader with corrected offset. | +| AT HOP | MIFARE DESFire EV1 with locked transit files. Public research exposes serial-level data more readily than balance. | DESFire identification/logging path. | +| Snapper | KSX6924-family card path in public research and CardBal AID table. | Added AID probing through KSX6924 reader. | + +## Validation Notes + +`swift test` is a macOS package invocation and fails because the macOS toolchain lacks CoreNFC. The project is iOS-only, so the validation target is: + +```bash +xcodebuild -project Example/CENFC.xcodeproj -scheme CENFC -destination 'generic/platform=iOS Simulator' build +``` + +The iOS simulator build passed after these changes. + +For the user's physical cards, the best next capture is one scan per card with NFC logging enabled. The key log fields are: + +- FeliCa: system code, service versions, raw balance block, raw history blocks. +- ISO 7816: selected AID, APDU command path, status words, raw balance bytes. +- DESFire: selected applications and file metadata where available. From e92bfbcbc4e0fce98a3bc17561e78db3752d94c3 Mon Sep 17 00:00:00 2001 From: Lakr Date: Sat, 9 May 2026 00:46:47 +0900 Subject: [PATCH 02/10] Bump app version --- Example/CENFC/Configuration/Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Example/CENFC/Configuration/Version.xcconfig b/Example/CENFC/Configuration/Version.xcconfig index 2d71c6b..03d9ca4 100644 --- a/Example/CENFC/Configuration/Version.xcconfig +++ b/Example/CENFC/Configuration/Version.xcconfig @@ -1,2 +1,2 @@ -MARKETING_VERSION = 1.0.7 -CURRENT_PROJECT_VERSION = 14 +MARKETING_VERSION = 1.0.8 +CURRENT_PROJECT_VERSION = 15 From 331b4fe9bc72914e2dc03b38ad29164c2a617a31 Mon Sep 17 00:00:00 2001 From: Lakr Date: Sat, 9 May 2026 00:52:53 +0900 Subject: [PATCH 03/10] Add T-Union NFC select identifier --- Example/CENFC/Resources/Info.plist | 1 + Example/CENFCTests/NFCConfigurationTests.swift | 18 ++++++++++++++++++ docs/NFC-InfoPlist-Reference.md | 6 ++++++ docs/Transit-Card-Research.md | 2 +- 4 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 Example/CENFCTests/NFCConfigurationTests.swift diff --git a/Example/CENFC/Resources/Info.plist b/Example/CENFC/Resources/Info.plist index 93744af..cde9e02 100644 --- a/Example/CENFC/Resources/Info.plist +++ b/Example/CENFC/Resources/Info.plist @@ -143,6 +143,7 @@ D4100000030001 D4100000140001 D410000029000001 + A000000632010105 A000000341000101 5041592E535A54 D2760000850100 diff --git a/Example/CENFCTests/NFCConfigurationTests.swift b/Example/CENFCTests/NFCConfigurationTests.swift new file mode 100644 index 0000000..d7c81a1 --- /dev/null +++ b/Example/CENFCTests/NFCConfigurationTests.swift @@ -0,0 +1,18 @@ +import Foundation +import Testing + +struct NFCConfigurationTests { + @Test + func `ISO7816 select identifiers include implemented transit AIDs`() throws { + let identifiers = try #require( + Bundle.main.object( + forInfoDictionaryKey: "com.apple.developer.nfc.readersession.iso7816.select-identifiers" + ) as? [String] + ) + + #expect(identifiers.contains("A000000632010105")) // China T-Union + #expect(identifiers.contains("5041592E535A54")) // Legacy Shenzhen Tong + #expect(identifiers.contains("A000000341000101")) // Singapore CEPAS discovery + #expect(identifiers.contains("D4100000030001")) // KSX6924 / Snapper-compatible discovery + } +} diff --git a/docs/NFC-InfoPlist-Reference.md b/docs/NFC-InfoPlist-Reference.md index b267683..d81a431 100644 --- a/docs/NFC-InfoPlist-Reference.md +++ b/docs/NFC-InfoPlist-Reference.md @@ -50,6 +50,9 @@ These are the merged values collected from public iOS NFC apps and SDKs on GitHu D4100000030001 D4100000140001 D410000029000001 + A000000632010105 + A000000341000101 + 5041592E535A54 D2760000850100 F049442E43484E A000000812010208 @@ -100,6 +103,9 @@ These are the merged values collected from public iOS NFC apps and SDKs on GitHu | `D4100000030001` | Korean transit application | Included by passport / identity apps that also support common transit-card detection. | | `D4100000140001` | Korean transit application | Commonly associated with Cashbee-family cards in public NFC examples. | | `D410000029000001` | Public sample AID from iOS NFC app configs | Preserved because it appears in community NFC setups; exact issuer mapping is still unclear. | +| `A000000632010105` | China T-Union transit application | Required for the implemented T-Union balance reader and for CoreNFC ISO 7816 APDU access. | +| `A000000341000101` | Singapore CEPAS / EZ-Link discovery value | Included for transit-card discovery and logging while detailed balance support is researched. | +| `5041592E535A54` | Legacy Shenzhen Tong application (`PAY.SZT`) | Included for discovery and logging; confirmed T-Union balance support uses `A000000632010105`. | | `F049442E43484E` | China document application (observed) | Seen in packaged Chinese iOS apps; likely document-related, inferred from the ASCII tail `ID.CHN`. | | `A000000812010208` | Tangem card application | Documented by `tangem-sdk-ios`. | | `A00000045645444C2D3031` | Dutch driving licence application | Used by public ID verification SDKs for Dutch mobile document reading. | diff --git a/docs/Transit-Card-Research.md b/docs/Transit-Card-Research.md index 2da7cab..97a7413 100644 --- a/docs/Transit-Card-Research.md +++ b/docs/Transit-Card-Research.md @@ -67,7 +67,7 @@ CardBal confirms the T-Union balance flow: CoreExtendedNFC now reads both purse slots and logs SELECT, each purse APDU response, and the final parsed value. Existing Shenzhen and Nanjing T-Union cards should follow this same AID path when the card exposes the national transport application. -The older Shenzhen Tong AID `5041592E535A54` has been added to the sample app polling identifiers for discovery and logging. Confirmed balance support uses the T-Union AID path above. +The T-Union AID `A000000632010105` is required in the sample app polling identifiers for CoreNFC ISO 7816 APDU access. The older Shenzhen Tong AID `5041592E535A54` is also present for discovery and logging. Confirmed balance support uses the T-Union AID path above. ### Snapper / KSX6924 From f5cffa87a79d95f1a69d6ece2aa23ace0fdee0f4 Mon Sep 17 00:00:00 2001 From: Lakr Date: Sat, 9 May 2026 00:56:09 +0900 Subject: [PATCH 04/10] Mirror CardBal NFC identifiers --- Example/CENFC/Resources/Info.plist | 6 ++++++ Example/CENFCTests/NFCConfigurationTests.swift | 18 ++++++++++++++++++ docs/NFC-InfoPlist-Reference.md | 12 ++++++++++++ docs/Transit-Card-Research.md | 2 +- 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Example/CENFC/Resources/Info.plist b/Example/CENFC/Resources/Info.plist index cde9e02..e0a0c4a 100644 --- a/Example/CENFC/Resources/Info.plist +++ b/Example/CENFC/Resources/Info.plist @@ -129,6 +129,9 @@ 90B7 927A 86A7 + 80DE + 865E + 8592 com.apple.developer.nfc.readersession.iso7816.select-identifiers @@ -138,11 +141,14 @@ A0000002472001 A000000167455349474E A000000291 + A000000404 A00000000386980701 A0000004520001 D4100000030001 D4100000140001 D410000029000001 + D4100000300001 + D4106509900020 A000000632010105 A000000341000101 5041592E535A54 diff --git a/Example/CENFCTests/NFCConfigurationTests.swift b/Example/CENFCTests/NFCConfigurationTests.swift index d7c81a1..b461a46 100644 --- a/Example/CENFCTests/NFCConfigurationTests.swift +++ b/Example/CENFCTests/NFCConfigurationTests.swift @@ -11,8 +11,26 @@ struct NFCConfigurationTests { ) #expect(identifiers.contains("A000000632010105")) // China T-Union + #expect(identifiers.contains("A000000404")) // CardBal transit / stored-value app #expect(identifiers.contains("5041592E535A54")) // Legacy Shenzhen Tong #expect(identifiers.contains("A000000341000101")) // Singapore CEPAS discovery #expect(identifiers.contains("D4100000030001")) // KSX6924 / Snapper-compatible discovery + #expect(identifiers.contains("D4100000300001")) // Snapper / MOIBA + #expect(identifiers.contains("D4106509900020")) // K-Cash + } + + @Test + func `FeliCa system codes include CardBal transit codes`() throws { + let systemCodes = try #require( + Bundle.main.object( + forInfoDictionaryKey: "com.apple.developer.nfc.readersession.felica.systemcodes" + ) as? [String] + ) + + #expect(systemCodes.contains("0003")) // Japan IC + #expect(systemCodes.contains("8008")) // Octopus + #expect(systemCodes.contains("80DE")) // CardBal transit code + #expect(systemCodes.contains("865E")) // CardBal transit code + #expect(systemCodes.contains("8592")) // CardBal transit code } } diff --git a/docs/NFC-InfoPlist-Reference.md b/docs/NFC-InfoPlist-Reference.md index d81a431..2be21b7 100644 --- a/docs/NFC-InfoPlist-Reference.md +++ b/docs/NFC-InfoPlist-Reference.md @@ -31,6 +31,9 @@ These are the merged values collected from public iOS NFC apps and SDKs on GitHu 90B7 927A 86A7 + 80DE + 865E + 8592 ``` @@ -45,11 +48,14 @@ These are the merged values collected from public iOS NFC apps and SDKs on GitHu A0000002472001 A000000167455349474E A000000291 + A000000404 A00000000386980701 A0000004520001 D4100000030001 D4100000140001 D410000029000001 + D4100000300001 + D4106509900020 A000000632010105 A000000341000101 5041592E535A54 @@ -86,6 +92,9 @@ These are the merged values collected from public iOS NFC apps and SDKs on GitHu | `90B7` | Public sample value from iOS NFC OSS apps | Included in `react-native-nfc-manager`; exact card family not clearly documented in that repo. | | `927A` | Public sample value from iOS NFC OSS apps | Included in `react-native-nfc-manager`; exact card family not clearly documented in that repo. | | `86A7` | Additional Suica-related community value | Included in `react-native-nfc-manager`; often reported alongside Suica support. | +| `80DE` | CardBal transit-card system code | Included to mirror CardBal's FeliCa polling list. | +| `865E` | CardBal transit-card system code | Included to mirror CardBal's FeliCa polling list. | +| `8592` | CardBal transit-card system code | Included to mirror CardBal's FeliCa polling list. | ## ISO 7816 AIDs @@ -98,11 +107,14 @@ These are the merged values collected from public iOS NFC apps and SDKs on GitHu | `A0000002472001` | ICAO travel document auxiliary application | Commonly included by ID verification SDKs for passports and national ID cards. | | `A000000167455349474E` | eSign application | The ASCII tail decodes to `ESIGN`. Common in European eID / signing-card configs. | | `A000000291` | Calypso transit AID prefix | Often used as a prefix-style match for Calypso transit cards. | +| `A000000404` | CardBal transit / stored-value application | Included to mirror CardBal's ISO 7816 polling list. | | `A00000000386980701` | UnionPay payment application | Observed in packaged UnionPay-family iOS apps; exact post-select APDUs are issuer-specific. | | `A0000004520001` | Korean transit / stored-value ecosystem application | Seen in public mobile NFC configs used for Korean transit cards. | | `D4100000030001` | Korean transit application | Included by passport / identity apps that also support common transit-card detection. | | `D4100000140001` | Korean transit application | Commonly associated with Cashbee-family cards in public NFC examples. | | `D410000029000001` | Public sample AID from iOS NFC app configs | Preserved because it appears in community NFC setups; exact issuer mapping is still unclear. | +| `D4100000300001` | KSX6924 Snapper / MOIBA-compatible transit application | Present in CardBal and used by the KSX6924 reader's Snapper / MOIBA probing path. | +| `D4106509900020` | KSX6924 K-Cash transit application | Present in CardBal and used by the KSX6924 reader's K-Cash probing path. | | `A000000632010105` | China T-Union transit application | Required for the implemented T-Union balance reader and for CoreNFC ISO 7816 APDU access. | | `A000000341000101` | Singapore CEPAS / EZ-Link discovery value | Included for transit-card discovery and logging while detailed balance support is researched. | | `5041592E535A54` | Legacy Shenzhen Tong application (`PAY.SZT`) | Included for discovery and logging; confirmed T-Union balance support uses `A000000632010105`. | diff --git a/docs/Transit-Card-Research.md b/docs/Transit-Card-Research.md index 97a7413..ecb1c1a 100644 --- a/docs/Transit-Card-Research.md +++ b/docs/Transit-Card-Research.md @@ -80,7 +80,7 @@ CardBal includes the KSX6924-family AID set: - Snapper / MOIBA: `D4100000300001` - K-Cash: `D4106509900020` -CoreExtendedNFC now tries these AIDs in that order and logs SELECT outcomes, balance APDU responses, and record reads. The balance APDU remains the KSX6924 command `90 4C 00 00`, `Le=04`. +CoreExtendedNFC now tries these AIDs in that order and logs SELECT outcomes, balance APDU responses, and record reads. The sample app `Info.plist` mirrors these AIDs so CoreNFC can surface and transceive with those ISO 7816 applications. The balance APDU remains the KSX6924 command `90 4C 00 00`, `Le=04`. ## Researched Cards From fa617ed3cb7dd9b12598e95487f1f71b35dfa7fa Mon Sep 17 00:00:00 2001 From: Lakr Date: Sat, 9 May 2026 01:22:41 +0900 Subject: [PATCH 05/10] Fix transit card balance readers --- .../Cards/Transit/China/TUnionConstants.swift | 26 ++- .../Cards/Transit/China/TUnionReader.swift | 187 ++++++++++++++++-- .../Transit/HongKong/OctopusConstants.swift | 28 ++- .../Transit/HongKong/OctopusReader.swift | 22 ++- .../Cards/Transit/Japan/JapanICReader.swift | 18 +- .../Cards/Transit/Korea/KSX6924Reader.swift | 26 ++- .../Cards/Transit/TransitBalance.swift | 22 ++- Tests/CoreExtendedNFCTests/JapanICTests.swift | 14 +- Tests/CoreExtendedNFCTests/KSX6924Tests.swift | 21 +- .../CoreExtendedNFCTests/MockTransport.swift | 8 + Tests/CoreExtendedNFCTests/OctopusTests.swift | 37 +++- Tests/CoreExtendedNFCTests/TUnionTests.swift | 124 ++++++++++-- .../TransitBalanceTests.swift | 16 ++ docs/Transit-Card-Research.md | 83 +++++--- 14 files changed, 528 insertions(+), 104 deletions(-) create mode 100644 Tests/CoreExtendedNFCTests/TransitBalanceTests.swift diff --git a/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionConstants.swift b/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionConstants.swift index 677e2fc..7b40c47 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionConstants.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionConstants.swift @@ -5,6 +5,7 @@ import Foundation /// ## References /// - T-Union AID: A000000632010105 /// - Metrodroid ChinaTransitData / TUnionTransitData +/// - FareBot ChinaCard / TUnionTransitInfo /// - NFSee: https://github.com/niceda/NFSee /// /// ## Note @@ -16,9 +17,21 @@ enum TUnionConstants { // MARK: - File IDs - /// Main file containing balance, serial, and validity info. + /// Main file containing serial and validity info. static let balanceFileID = Data([0x00, 0x15]) + /// T-Union transaction record SFI. CardBal documents this as a circular + /// 0x17-byte transaction/top-up record file. + static let transactionSFI: UInt8 = 0x18 + static let transactionRecordLength: UInt8 = 0x17 + static let maxTransactionRecords = 10 + + /// T-Union transit activity SFI. CardBal documents this as a circular + /// 0x30-byte travel activity record file. + static let transitActivitySFI: UInt8 = 0x1E + static let transitActivityRecordLength: UInt8 = 0x30 + static let maxTransitActivityRecords = 30 + // MARK: - GET BALANCE Command /// Proprietary GET BALANCE: CLA=0x80 INS=0x5C P1=0x00 P2=0x02. @@ -40,4 +53,15 @@ enum TUnionConstants { /// Validity end: bytes 24-27 (4 bytes hex date YYYYMMDD). static let validUntilOffset = 24 static let validUntilLength = 4 + + // MARK: - Transaction Record Layout + + static let transactionAmountOffset = 5 + static let transactionAmountLength = 4 + static let transactionTypeOffset = 9 + static let transactionStationOffset = 10 + static let transactionStationLength = 6 + static let transactionDateTimeOffset = 16 + static let transactionDateTimeLength = 7 + static let topUpType: UInt8 = 0x02 } diff --git a/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionReader.swift b/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionReader.swift index ab0a613..dd6fd11 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionReader.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionReader.swift @@ -5,8 +5,9 @@ import Foundation /// ## Protocol Overview /// 1. SELECT T-Union AID (A000000632010105) /// 2. GET BALANCE: CLA=0x80 INS=0x5C P1=0x00 P2=0x02 → 4 bytes -/// Balance = bits 1-31 as signed integer (CNY fen, divide by 100 for yuan) -/// 3. SELECT file 0x15 + READ BINARY for serial number and validity dates +/// Balance = low 31 bits of a big-endian UInt32 in CNY fen, divide by 100 for yuan. +/// 3. SELECT file 0x15 + READ BINARY for serial number and validity dates. +/// 4. READ RECORD from SFI 0x18 and 0x1E for recent transactions/trips. /// /// ## iOS Scope /// - T-Union branded cards with the full AID work on iOS. @@ -27,21 +28,24 @@ public struct TUnionReader: Sendable { NFCLog.info("T-Union balance read start", source: "TUnion") // 1. SELECT T-Union AID let selectResponse = try await transport.sendAPDUWithChaining( - CommandAPDU.select(aid: TUnionConstants.tUnionAID) + CommandAPDU.select(aid: TUnionConstants.tUnionAID), ) guard selectResponse.isSuccess else { throw NFCError.unsupportedOperation("T-Union AID not found on this card") } NFCLog.debug("T-Union SELECT AID ok data=\(selectResponse.data.hexString)", source: "TUnion") - // 2. GET BALANCE. CardBal reads balance slot 0 and slot 1, then uses slot0 - slot1. + // 2. GET BALANCE. CardBal reads both purse slots. The card preview uses + // slot 0 when it is populated, and falls back to slot0 - slot1 for cards + // that encode the primary purse as zero. let balance0 = try await readBalanceSlot(p1: TUnionConstants.GET_BALANCE_P1) let balance1 = await readOptionalBalanceSlot(p1: TUnionConstants.GET_NEGATIVE_BALANCE_P1) - let balanceFen = balance0 - balance1 + let balanceFen = Self.displayBalance(primary: balance0, negative: balance1) NFCLog.info("T-Union balance read complete balance0=\(balance0) balance1=\(balance1) final=\(balanceFen)", source: "TUnion") // 3. Read file 0x15 for serial and validity let fileInfo = await readFileInfo() + let transactions = await readTransactions() return TransitBalance( serialNumber: fileInfo.serial, @@ -49,7 +53,8 @@ public struct TUnionReader: Sendable { currencyCode: "CNY", cardName: "T-Union", validFrom: fileInfo.validFrom, - validUntil: fileInfo.validUntil + validUntil: fileInfo.validUntil, + transactions: transactions, ) } @@ -65,7 +70,7 @@ public struct TUnionReader: Sendable { let response = try await transport.sendAPDUWithChaining(Self.balanceAPDU(p1: p1)) NFCLog.debug( "T-Union GET BALANCE p1=\(String(format: "%02X", p1)) sw=\(String(format: "%02X%02X", response.sw1, response.sw2)) data=\(response.data.hexString)", - source: "TUnion" + source: "TUnion", ) guard response.isSuccess, response.data.count >= 4 else { throw NFCError.unexpectedStatusWord(response.sw1, response.sw2) @@ -89,29 +94,35 @@ public struct TUnionReader: Sendable { ins: TUnionConstants.GET_BALANCE_INS, p1: p1, p2: TUnionConstants.GET_BALANCE_P2, - le: TUnionConstants.GET_BALANCE_LE + le: TUnionConstants.GET_BALANCE_LE, ) } static func parseBalanceFen(_ data: Data) -> Int { let rawValue = Data(data.prefix(4)).uint32BE - return Int(rawValue >> 1) + return Int(rawValue & 0x7FFF_FFFF) + } + + static func displayBalance(primary: Int, negative: Int) -> Int { + primary > 0 ? primary : primary - negative } private func readFileInfo() async -> FileInfo { do { // SELECT file 0x15 let selectFile = try await transport.sendAPDUWithChaining( - CommandAPDU.selectFile(id: TUnionConstants.balanceFileID) + Self.selectFileAPDU(id: TUnionConstants.balanceFileID), ) + NFCLog.debug("T-Union SELECT file 0015 sw=\(String(format: "%02X%02X", selectFile.sw1, selectFile.sw2)) data=\(selectFile.data.hexString)", source: "TUnion") guard selectFile.isSuccess else { return FileInfo(serial: "", validFrom: nil, validUntil: nil) } // READ BINARY: need at least 28 bytes (offset 0, covers through validity dates) let readResponse = try await transport.sendAPDUWithChaining( - CommandAPDU.readBinary(offset: 0, length: 30) + CommandAPDU.readBinary(offset: 0, length: 30), ) + NFCLog.debug("T-Union READ BINARY 0015 sw=\(String(format: "%02X%02X", readResponse.sw1, readResponse.sw2)) data=\(readResponse.data.hexString)", source: "TUnion") guard readResponse.isSuccess, readResponse.data.count >= 28 else { return FileInfo(serial: "", validFrom: nil, validUntil: nil) } @@ -136,6 +147,134 @@ public struct TUnionReader: Sendable { } } + private static func selectFileAPDU(id: Data) -> CommandAPDU { + CommandAPDU(cla: 0x00, ins: 0xA4, p1: 0x00, p2: 0x00, data: id, le: 0x00) + } + + private static func readRecordAPDU(sfi: UInt8, recordNumber: UInt8, length: UInt8) -> CommandAPDU { + CommandAPDU( + cla: 0x00, + ins: 0xB2, + p1: recordNumber, + p2: (sfi << 3) | 0x04, + le: length, + ) + } + + private func readTransactions() async -> [TransitTransaction] { + let transactions = await readRecords( + sfi: TUnionConstants.transactionSFI, + length: TUnionConstants.transactionRecordLength, + maxRecords: TUnionConstants.maxTransactionRecords, + parser: Self.parseTransactionRecord, + ) + let trips = await readRecords( + sfi: TUnionConstants.transitActivitySFI, + length: TUnionConstants.transitActivityRecordLength, + maxRecords: TUnionConstants.maxTransitActivityRecords, + parser: Self.parseTransitActivityRecord, + ) + let merged = (transactions + trips).sorted { lhs, rhs in + switch (lhs.date, rhs.date) { + case let (left?, right?): + left > right + case (_?, nil): + true + case (nil, _?): + false + case (nil, nil): + (lhs.recordNumber ?? 0) < (rhs.recordNumber ?? 0) + } + } + NFCLog.info("T-Union records read complete transactions=\(transactions.count) trips=\(trips.count)", source: "TUnion") + return merged + } + + private func readRecords( + sfi: UInt8, + length: UInt8, + maxRecords: Int, + parser: (Data, Int) -> TransitTransaction?, + ) async -> [TransitTransaction] { + var records: [TransitTransaction] = [] + for recordNumber in 1 ... maxRecords { + do { + let apdu = Self.readRecordAPDU(sfi: sfi, recordNumber: UInt8(recordNumber), length: length) + let response = try await transport.sendAPDUWithChaining(apdu) + NFCLog.debug( + "T-Union READ RECORD sfi=\(String(format: "%02X", sfi)) record=\(recordNumber) sw=\(String(format: "%02X%02X", response.sw1, response.sw2)) data=\(response.data.hexString)", + source: "TUnion", + ) + guard response.isSuccess else { + if response.statusWord == 0x6A83 || response.statusWord == 0x6A82 { + break + } + continue + } + if response.data.allSatisfy({ $0 == 0x00 }) { + continue + } + if let record = parser(response.data, recordNumber) { + records.append(record) + } + } catch { + NFCLog.debug("T-Union record read stopped sfi=\(String(format: "%02X", sfi)) record=\(recordNumber): \(error.localizedDescription)", source: "TUnion") + break + } + } + return records + } + + static func parseTransactionRecord(_ data: Data, recordNumber: Int) -> TransitTransaction? { + guard data.count >= Int(TUnionConstants.transactionRecordLength) else { return nil } + let amount = Int(Data(data[TUnionConstants.transactionAmountOffset ..< TUnionConstants.transactionAmountOffset + TUnionConstants.transactionAmountLength]).uint32BE) + let transactionType = data[TUnionConstants.transactionTypeOffset] + let dateBytes = Data(data[TUnionConstants.transactionDateTimeOffset ..< TUnionConstants.transactionDateTimeOffset + TUnionConstants.transactionDateTimeLength]) + if amount == 0, dateBytes.allSatisfy({ $0 == 0x00 }) { + return nil + } + + let isTopUp = transactionType == TUnionConstants.topUpType + let station = Data(data[TUnionConstants.transactionStationOffset ..< TUnionConstants.transactionStationOffset + TUnionConstants.transactionStationLength]).hexString + let signedAmount = isTopUp ? amount : -amount + return TransitTransaction( + type: isTopUp ? .topup : .purchase, + amount: signedAmount, + balanceAfter: 0, + date: parseBCDDateTime(dateBytes), + entryStation: station, + exitStation: nil, + recordNumber: recordNumber, + rawData: data, + metadata: [ + "source": "TUnion SFI 18", + "transactionType": String(format: "%02X", transactionType), + "station": station, + ], + ) + } + + static func parseTransitActivityRecord(_ data: Data, recordNumber: Int) -> TransitTransaction? { + guard data.count >= Int(TUnionConstants.transitActivityRecordLength) else { return nil } + if data.allSatisfy({ $0 == 0x00 }) { + return nil + } + return TransitTransaction( + type: .trip, + amount: 0, + balanceAfter: 0, + date: nil, + entryStation: nil, + exitStation: nil, + recordNumber: recordNumber, + rawData: data, + metadata: [ + "source": "TUnion SFI 1E", + "raw": data.hexString, + ], + ) + } + /// Parse a 4-byte hex-encoded date (YYYYMMDD) into a Date. static func parseHexDate(_ data: Data) -> Date? { guard data.count >= 4 else { return nil } @@ -158,4 +297,30 @@ public struct TUnionReader: Sendable { components.timeZone = TimeZone(identifier: "Asia/Shanghai") return Calendar(identifier: .gregorian).date(from: components) } + + static func parseBCDDateTime(_ data: Data) -> Date? { + guard data.count >= TUnionConstants.transactionDateTimeLength else { return nil } + let values = data.prefix(7).map { byte -> Int in + Int((byte >> 4) & 0x0F) * 10 + Int(byte & 0x0F) + } + let year = values[0] * 100 + values[1] + let month = values[2] + let day = values[3] + let hour = values[4] + let minute = values[5] + let second = values[6] + guard year > 0, month >= 1, month <= 12, day >= 1, day <= 31, + hour <= 23, minute <= 59, second <= 59 + else { return nil } + + var components = DateComponents() + components.year = year + components.month = month + components.day = day + components.hour = hour + components.minute = minute + components.second = second + components.timeZone = TimeZone(identifier: "Asia/Shanghai") + return Calendar(identifier: .gregorian).date(from: components) + } } diff --git a/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusConstants.swift b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusConstants.swift index 92cc6f3..c50fc37 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusConstants.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusConstants.swift @@ -4,6 +4,8 @@ import Foundation /// /// References: /// - TRETJapanNFCReader OctopusCardItemType / OctopusCardData +/// - Metrodroid OctopusData +/// - FareBot OctopusData /// - System code 0x8008, balance service 0x0117 enum OctopusConstants { /// Octopus FeliCa system code. @@ -12,9 +14,27 @@ enum OctopusConstants { /// Balance service code 0x0117, encoded little-endian for CoreNFC. static let balanceServiceCode = Data([0x17, 0x01]) - /// Current Octopus raw offset used by CardBal and older public writeups. - static let currentBalanceRawOffset = 350 + /// Raw offset used for Octopus cards before the 2017 negative-balance change. + static let pre2017BalanceRawOffset = 350 - /// Historical raw offset for very old Octopus records. - static let legacyBalanceRawOffset = 35 + /// Raw offset used for Octopus cards from 2017-10-01 onward. + static let currentBalanceRawOffset = 500 + + /// Compatibility alias for callers that explicitly request the older offset. + static let legacyBalanceRawOffset = pre2017BalanceRawOffset + + /// Effective date of the Octopus maximum negative-balance change, in Hong Kong time. + static let currentOffsetStartDate: Date = { + var components = DateComponents() + components.calendar = Calendar(identifier: .gregorian) + components.timeZone = TimeZone(identifier: "Asia/Hong_Kong") + components.year = 2017 + components.month = 10 + components.day = 1 + return components.date ?? Date(timeIntervalSince1970: 1_506_787_200) + }() + + static func balanceRawOffset(for scanDate: Date) -> Int { + scanDate >= currentOffsetStartDate ? currentBalanceRawOffset : pre2017BalanceRawOffset + } } diff --git a/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift index 8da5543..c2e5407 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift @@ -7,12 +7,20 @@ public struct OctopusReader: Sendable { public init(transport: any FeliCaTagTransporting) { self.transport = transport - balanceOffset = OctopusConstants.currentBalanceRawOffset + balanceOffset = OctopusConstants.balanceRawOffset(for: Date()) } public init( transport: any FeliCaTagTransporting, - balanceOffset: Int + scanDate: Date, + ) { + self.transport = transport + balanceOffset = OctopusConstants.balanceRawOffset(for: scanDate) + } + + public init( + transport: any FeliCaTagTransporting, + balanceOffset: Int, ) { self.transport = transport self.balanceOffset = balanceOffset @@ -24,7 +32,7 @@ public struct OctopusReader: Sendable { NFCLog.debug("Octopus request service balance=\(OctopusConstants.balanceServiceCode.hexString)", source: "Octopus") let versions = try await transport.requestService( - nodeCodeList: [OctopusConstants.balanceServiceCode] + nodeCodeList: [OctopusConstants.balanceServiceCode], ) NFCLog.debug("Octopus balance service versions=\(versions.map(\.hexString).joined(separator: ","))", source: "Octopus") guard let version = versions.first, version != Data([0xFF, 0xFF]) else { @@ -33,7 +41,7 @@ public struct OctopusReader: Sendable { let blocks = try await transport.readWithoutEncryption( serviceCode: OctopusConstants.balanceServiceCode, - blockList: [FeliCaFrame.blockListElement(blockNumber: 0)] + blockList: [FeliCaFrame.blockListElement(blockNumber: 0)], ) guard let block = blocks.first, block.count >= 4 else { NFCLog.error("Octopus invalid balance block=\((blocks.first ?? Data()).hexString)", source: "Octopus") @@ -44,7 +52,7 @@ public struct OctopusReader: Sendable { let balanceCents = Self.balanceCents(rawValue: rawValue, offset: balanceOffset) NFCLog.debug( "Octopus balance block=\(block.hexString) raw=\(rawValue) offset=\(balanceOffset) cents=\(balanceCents)", - source: "Octopus" + source: "Octopus", ) NFCLog.info("Octopus balance read complete balanceCents=\(balanceCents)", source: "Octopus") @@ -52,7 +60,7 @@ public struct OctopusReader: Sendable { serialNumber: transport.identifier.hexString, balanceRaw: balanceCents, currencyCode: "HKD", - cardName: "Octopus" + cardName: "Octopus", ) } @@ -64,7 +72,7 @@ public struct OctopusReader: Sendable { NFCLog.debug("Octopus system code=\(transport.systemCode.hexString) expected=\(OctopusConstants.systemCode.hexString)", source: "Octopus") guard transport.systemCode == OctopusConstants.systemCode else { throw NFCError.unsupportedOperation( - "Expected FeliCa system code 0x8008 (Octopus), got \(transport.systemCode.hexString)" + "Expected FeliCa system code 0x8008 (Octopus), got \(transport.systemCode.hexString)", ) } } diff --git a/Sources/CoreExtendedNFC/Cards/Transit/Japan/JapanICReader.swift b/Sources/CoreExtendedNFC/Cards/Transit/Japan/JapanICReader.swift index fa61918..be68a1c 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/Japan/JapanICReader.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/Japan/JapanICReader.swift @@ -9,7 +9,7 @@ import Foundation /// /// ## References /// - CJRC system code 0x0003 -/// - Balance: service 0x008B, 1 block, bytes 0x0A-0x0B LE (JPY) +/// - Balance: service 0x008B, 1 block, bytes 0x0B-0x0C LE (JPY) /// - History: service 0x090F, up to 20 blocks (cyclic) public struct JapanICReader: Sendable { let transport: any FeliCaTagTransporting @@ -28,7 +28,7 @@ public struct JapanICReader: Sendable { serialNumber: transport.identifier.hexString, balanceRaw: balance, currencyCode: "JPY", - cardName: "Japan IC" + cardName: "Japan IC", ) } @@ -44,7 +44,7 @@ public struct JapanICReader: Sendable { balanceRaw: balance, currencyCode: "JPY", cardName: "Japan IC", - transactions: transactions + transactions: transactions, ) } @@ -54,7 +54,7 @@ public struct JapanICReader: Sendable { NFCLog.debug("Japan IC system code=\(transport.systemCode.hexString) expected=\(JapanICConstants.systemCode.hexString)", source: "JapanIC") guard transport.systemCode == JapanICConstants.systemCode else { throw NFCError.unsupportedOperation( - "Expected FeliCa system code 0x0003 (CJRC), got \(transport.systemCode.hexString)" + "Expected FeliCa system code 0x0003 (CJRC), got \(transport.systemCode.hexString)", ) } } @@ -63,7 +63,7 @@ public struct JapanICReader: Sendable { // Verify balance service exists NFCLog.debug("Japan IC request service balance=\(JapanICConstants.balanceServiceCode.hexString)", source: "JapanIC") let versions = try await transport.requestService( - nodeCodeList: [JapanICConstants.balanceServiceCode] + nodeCodeList: [JapanICConstants.balanceServiceCode], ) NFCLog.debug("Japan IC balance service versions=\(versions.map(\.hexString).joined(separator: ","))", source: "JapanIC") guard let version = versions.first, @@ -75,7 +75,7 @@ public struct JapanICReader: Sendable { // Read single balance block let blocks = try await transport.readWithoutEncryption( serviceCode: JapanICConstants.balanceServiceCode, - blockList: [FeliCaFrame.blockListElement(blockNumber: 0)] + blockList: [FeliCaFrame.blockListElement(blockNumber: 0)], ) guard let block = blocks.first, block.count >= JapanICConstants.balanceOffset + 2 @@ -88,7 +88,7 @@ public struct JapanICReader: Sendable { let balance = Int(balanceBytes.uint16LE) NFCLog.debug( "Japan IC balance block=\(block.hexString) offset=\(JapanICConstants.balanceOffset) bytes=\(balanceBytes.hexString) value=\(balance)", - source: "JapanIC" + source: "JapanIC", ) return balance } @@ -102,7 +102,7 @@ public struct JapanICReader: Sendable { do { let blocks = try await transport.readWithoutEncryption( serviceCode: JapanICConstants.historyServiceCode, - blockList: [FeliCaFrame.blockListElement(blockNumber: UInt16(i))] + blockList: [FeliCaFrame.blockListElement(blockNumber: UInt16(i))], ) guard let block = blocks.first, block.count == 16 else { NFCLog.debug("Japan IC history block #\(i) invalid=\((blocks.first ?? Data()).hexString)", source: "JapanIC") @@ -163,7 +163,7 @@ public struct JapanICReader: Sendable { balanceAfter: balanceAfter, date: date, entryStation: entryStation, - exitStation: exitStation + exitStation: exitStation, ) } diff --git a/Sources/CoreExtendedNFC/Cards/Transit/Korea/KSX6924Reader.swift b/Sources/CoreExtendedNFC/Cards/Transit/Korea/KSX6924Reader.swift index 11b78b7..f748a5b 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/Korea/KSX6924Reader.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/Korea/KSX6924Reader.swift @@ -33,7 +33,7 @@ public struct KSX6924Reader: Sendable { currencyCode: "KRW", cardName: cardName, validFrom: purseInfo.issueDate, - validUntil: purseInfo.expiryDate + validUntil: purseInfo.expiryDate, ) } @@ -53,7 +53,7 @@ public struct KSX6924Reader: Sendable { cardName: cardName, validFrom: purseInfo.issueDate, validUntil: purseInfo.expiryDate, - transactions: transactions + transactions: transactions, ) } @@ -63,12 +63,18 @@ public struct KSX6924Reader: Sendable { private func selectCard() async throws -> (String, Data) { for (name, aid) in KSX6924Constants.allAIDs { NFCLog.debug("KSX6924 SELECT AID \(aid.hexString) (\(name))", source: "KSX6924") - let response = try await transport.sendAPDUWithChaining(CommandAPDU.select(aid: aid)) - if response.isSuccess { - NFCLog.debug("KSX6924 SELECT AID success card=\(name) data=\(response.data.hexString)", source: "KSX6924") - return (name, response.data) + do { + let response = try await transport.sendAPDUWithChaining(CommandAPDU.select(aid: aid)) + if response.isSuccess { + NFCLog.debug("KSX6924 SELECT AID success card=\(name) data=\(response.data.hexString)", source: "KSX6924") + return (name, response.data) + } + NFCLog.debug("KSX6924 SELECT AID rejected card=\(name) sw=\(String(format: "%02X%02X", response.sw1, response.sw2))", source: "KSX6924") + } catch let NFCError.unexpectedStatusWord(sw1, sw2) { + NFCLog.debug("KSX6924 SELECT AID threw status card=\(name) sw=\(String(format: "%02X%02X", sw1, sw2))", source: "KSX6924") + } catch let NFCError.unsupportedOperation(message) { + NFCLog.debug("KSX6924 SELECT AID unsupported card=\(name): \(message)", source: "KSX6924") } - NFCLog.debug("KSX6924 SELECT AID rejected card=\(name) sw=\(String(format: "%02X%02X", response.sw1, response.sw2))", source: "KSX6924") } throw NFCError.unsupportedOperation("No supported KS X 6924 AID found on this card") } @@ -79,7 +85,7 @@ public struct KSX6924Reader: Sendable { ins: KSX6924Constants.INS_GET_BALANCE, p1: 0x00, p2: 0x00, - le: KSX6924Constants.BALANCE_RESP_LEN + le: KSX6924Constants.BALANCE_RESP_LEN, ) let response = try await transport.sendAPDUWithChaining(apdu) NFCLog.debug("KSX6924 GET BALANCE sw=\(String(format: "%02X%02X", response.sw1, response.sw2)) data=\(response.data.hexString)", source: "KSX6924") @@ -99,7 +105,7 @@ public struct KSX6924Reader: Sendable { ins: KSX6924Constants.INS_GET_RECORD, p1: UInt8(i), p2: 0x00, - le: 0x10 + le: 0x10, ) let response = try await transport.sendAPDUWithChaining(apdu) guard response.isSuccess, response.data.count >= 14 else { break } @@ -194,7 +200,7 @@ public struct KSX6924Reader: Sendable { type: txType, amount: amount, balanceAfter: balance, - date: date + date: date, ) } } diff --git a/Sources/CoreExtendedNFC/Cards/Transit/TransitBalance.swift b/Sources/CoreExtendedNFC/Cards/Transit/TransitBalance.swift index 31b747a..798185b 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/TransitBalance.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/TransitBalance.swift @@ -4,9 +4,9 @@ import Foundation public struct TransitBalance: Sendable, Equatable, Codable { /// Card identifier / serial number (hex string). public let serialNumber: String - /// Current balance in the smallest currency unit (yen, won, fen). + /// Current balance in the card currency's stored unit, such as yen, won, dollars, cents, or fen. public let balanceRaw: Int - /// ISO 4217 currency code: "JPY", "KRW", "CNY". + /// ISO 4217 currency code, such as "JPY", "KRW", "CNY", "HKD", "SGD", "NZD", or "TWD". public let currencyCode: String /// Card type name for display (e.g. "Suica", "T-Money"). public let cardName: String @@ -24,7 +24,7 @@ public struct TransitBalance: Sendable, Equatable, Codable { cardName: String, validFrom: Date? = nil, validUntil: Date? = nil, - transactions: [TransitTransaction] = [] + transactions: [TransitTransaction] = [], ) { self.serialNumber = serialNumber self.balanceRaw = balanceRaw @@ -45,6 +45,8 @@ public struct TransitBalance: Sendable, Equatable, Codable { case "CNY": let yuan = Double(balanceRaw) / 100.0 return String(format: "¥%.2f", yuan) + case "TWD": + return "NT$\(balanceRaw)" case "HKD": let dollars = Double(balanceRaw) / 100.0 return String(format: "HK$%.2f", dollars) @@ -74,6 +76,12 @@ public struct TransitTransaction: Sendable, Equatable, Codable { public let entryStation: String? /// Exit station code (hex string), if available. public let exitStation: String? + /// Source record number, if the card exposes cyclic record slots. + public let recordNumber: Int? + /// Raw record payload, if retained by the reader. + public let rawData: Data? + /// Reader-specific decoded fields. + public let metadata: [String: String] public init( type: TransactionType, @@ -81,7 +89,10 @@ public struct TransitTransaction: Sendable, Equatable, Codable { balanceAfter: Int, date: Date? = nil, entryStation: String? = nil, - exitStation: String? = nil + exitStation: String? = nil, + recordNumber: Int? = nil, + rawData: Data? = nil, + metadata: [String: String] = [:], ) { self.type = type self.amount = amount @@ -89,6 +100,9 @@ public struct TransitTransaction: Sendable, Equatable, Codable { self.date = date self.entryStation = entryStation self.exitStation = exitStation + self.recordNumber = recordNumber + self.rawData = rawData + self.metadata = metadata } } diff --git a/Tests/CoreExtendedNFCTests/JapanICTests.swift b/Tests/CoreExtendedNFCTests/JapanICTests.swift index 7265042..7f8b26c 100644 --- a/Tests/CoreExtendedNFCTests/JapanICTests.swift +++ b/Tests/CoreExtendedNFCTests/JapanICTests.swift @@ -2,7 +2,7 @@ // // ## References // - CJRC system code 0x0003 -// - Balance: service 0x008B, 1 block, bytes 0x0A-0x0B little-endian (JPY) +// - Balance: service 0x008B, 1 block, bytes 0x0B-0x0C little-endian (JPY) // - History: service 0x090F, up to 20 blocks (cyclic) // - Date encoding: 2 bytes BE, year(7) month(4) day(5), base 2000 @testable import CoreExtendedNFC @@ -22,7 +22,7 @@ struct JapanICTests { let transport = MockFeliCaServiceTransport( serviceVersions: [Data([0x8B, 0x00]): Data([0x00, 0x10])], serviceBlocks: [Data([0x8B, 0x00]): [balanceBlock]], - systemCode: Data([0x00, 0x03]) + systemCode: Data([0x00, 0x03]), ) let reader = JapanICReader(transport: transport) @@ -64,7 +64,7 @@ struct JapanICTests { Data([0x8B, 0x00]): [balanceBlock], Data([0x0F, 0x09]): [historyBlock], ], - systemCode: Data([0x00, 0x03]) + systemCode: Data([0x00, 0x03]), ) let reader = JapanICReader(transport: transport) @@ -84,7 +84,7 @@ struct JapanICTests { func `System code mismatch throws error`() async { let transport = MockFeliCaServiceTransport( serviceVersions: [:], - systemCode: Data([0x88, 0xB4]) // wrong system code + systemCode: Data([0x88, 0xB4]), // wrong system code ) let reader = JapanICReader(transport: transport) @@ -97,7 +97,7 @@ struct JapanICTests { func `Balance service unavailable throws error`() async { let transport = MockFeliCaServiceTransport( serviceVersions: [Data([0x8B, 0x00]): Data([0xFF, 0xFF])], // service not found - systemCode: Data([0x00, 0x03]) + systemCode: Data([0x00, 0x03]), ) let reader = JapanICReader(transport: transport) @@ -122,7 +122,7 @@ struct JapanICTests { let calendar = Calendar(identifier: .gregorian) let components = try calendar.dateComponents( in: #require(TimeZone(identifier: "Asia/Tokyo")), - from: #require(date) + from: #require(date), ) #expect(components.year == 2025) #expect(components.month == 1) @@ -189,7 +189,7 @@ struct JapanICTests { let transport = MockFeliCaServiceTransport( serviceVersions: [Data([0x8B, 0x00]): Data([0x00, 0x10])], serviceBlocks: [Data([0x8B, 0x00]): [balanceBlock]], - systemCode: Data([0x00, 0x03]) + systemCode: Data([0x00, 0x03]), ) let reader = JapanICReader(transport: transport) diff --git a/Tests/CoreExtendedNFCTests/KSX6924Tests.swift b/Tests/CoreExtendedNFCTests/KSX6924Tests.swift index 7e1cb3e..9ad61c7 100644 --- a/Tests/CoreExtendedNFCTests/KSX6924Tests.swift +++ b/Tests/CoreExtendedNFCTests/KSX6924Tests.swift @@ -50,6 +50,25 @@ struct KSX6924Tests { #expect(result.balanceRaw == 5000) } + @Test + func `Continues AID selection after thrown status word`() async throws { + let transport = MockTransport() + transport.apduFailures = [ + 0: .unexpectedStatusWord(0x6A, 0x82), + ] + let fci = buildFCI(serial: "1234567890ABCDEF", issueDate: "20200101", expiryDate: "20301231") + transport.apduResponses = [ + ResponseAPDU(data: fci, sw1: 0x90, sw2: 0x00), + ResponseAPDU(data: Data([0x00, 0x00, 0x27, 0x10]), sw1: 0x90, sw2: 0x00), + ] + + let reader = KSX6924Reader(transport: transport) + let result = try await reader.readBalance() + + #expect(result.cardName == "T-Money") + #expect(result.balanceRaw == 10000) + } + @Test func `All AIDs fail throws error`() async { let transport = MockTransport() @@ -99,7 +118,7 @@ struct KSX6924Tests { let calendar = Calendar(identifier: .gregorian) let components = try calendar.dateComponents( in: #require(TimeZone(identifier: "Asia/Seoul")), - from: #require(date) + from: #require(date), ) #expect(components.year == 2025) #expect(components.month == 12) diff --git a/Tests/CoreExtendedNFCTests/MockTransport.swift b/Tests/CoreExtendedNFCTests/MockTransport.swift index b1f3808..a3ac61b 100644 --- a/Tests/CoreExtendedNFCTests/MockTransport.swift +++ b/Tests/CoreExtendedNFCTests/MockTransport.swift @@ -8,10 +8,12 @@ final class MockTransport: ISO7816TagTransporting, @unchecked Sendable { let identifier: Data var responses: [Data] = [] var apduResponses: [ResponseAPDU] = [] + var apduFailures: [Int: NFCError] = [:] var sentCommands: [Data] = [] var sentAPDUs: [CommandAPDU] = [] private var responseIndex = 0 private var apduResponseIndex = 0 + private var apduCallIndex = 0 init(identifier: Data = Data([0x04, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06])) { self.identifier = identifier @@ -29,6 +31,11 @@ final class MockTransport: ISO7816TagTransporting, @unchecked Sendable { func sendAPDU(_ apdu: CommandAPDU) async throws -> ResponseAPDU { sentAPDUs.append(apdu) + let callIndex = apduCallIndex + apduCallIndex += 1 + if let error = apduFailures[callIndex] { + throw error + } guard apduResponseIndex < apduResponses.count else { throw NFCError.tagConnectionLost } @@ -44,6 +51,7 @@ final class MockTransport: ISO7816TagTransporting, @unchecked Sendable { func reset() { responseIndex = 0 apduResponseIndex = 0 + apduCallIndex = 0 sentCommands.removeAll() sentAPDUs.removeAll() } diff --git a/Tests/CoreExtendedNFCTests/OctopusTests.swift b/Tests/CoreExtendedNFCTests/OctopusTests.swift index c9c6487..197bc93 100644 --- a/Tests/CoreExtendedNFCTests/OctopusTests.swift +++ b/Tests/CoreExtendedNFCTests/OctopusTests.swift @@ -4,7 +4,7 @@ // - System code: 0x8008 // - Balance service: 0x0117, encoded as 17 01 for CoreNFC // - Balance block: first 4 bytes big-endian raw value -// - Current balance: (raw - 350) / 10 HKD +// - Current balance: (raw - offset) * 10 HKD cents @testable import CoreExtendedNFC import Foundation import Testing @@ -21,36 +21,55 @@ struct OctopusTests { let transport = MockFeliCaServiceTransport( serviceVersions: [Data([0x17, 0x01]): Data([0x00, 0x10])], serviceBlocks: [Data([0x17, 0x01]): [block]], - systemCode: Data([0x80, 0x08]) + systemCode: Data([0x80, 0x08]), ) - let result = try await OctopusReader(transport: transport).readBalance() + let result = try await OctopusReader( + transport: transport, + scanDate: date(year: 2026, month: 5, day: 9), + ).readBalance() - #expect(result.balanceRaw == 42690) + #expect(result.balanceRaw == 41190) #expect(result.currencyCode == "HKD") #expect(result.cardName == "Octopus") - #expect(result.formattedBalance == "HK$426.90") + #expect(result.formattedBalance == "HK$411.90") } @Test - func `Octopus legacy offset remains available`() { + func `Octopus offset follows scan date`() { + #expect(OctopusConstants.balanceRawOffset(for: date(year: 2017, month: 9, day: 30)) == 350) + #expect(OctopusConstants.balanceRawOffset(for: date(year: 2017, month: 10, day: 1)) == 500) + } + + @Test + func `Octopus pre-2017 offset remains available`() { let cents = OctopusReader.balanceCents( rawValue: 4557, - offset: OctopusConstants.legacyBalanceRawOffset + offset: OctopusConstants.legacyBalanceRawOffset, ) - #expect(cents == 45220) + #expect(cents == 42070) } @Test func `Octopus system code mismatch throws error`() async { let transport = MockFeliCaServiceTransport( serviceVersions: [:], - systemCode: Data([0x00, 0x03]) + systemCode: Data([0x00, 0x03]), ) await #expect(throws: NFCError.self) { _ = try await OctopusReader(transport: transport).readBalance() } } + + private func date(year: Int, month: Int, day: Int) -> Date { + var components = DateComponents() + components.calendar = Calendar(identifier: .gregorian) + components.timeZone = TimeZone(identifier: "Asia/Hong_Kong") + components.year = year + components.month = month + components.day = day + return components.date! + } } diff --git a/Tests/CoreExtendedNFCTests/TUnionTests.swift b/Tests/CoreExtendedNFCTests/TUnionTests.swift index ab2ca29..a672576 100644 --- a/Tests/CoreExtendedNFCTests/TUnionTests.swift +++ b/Tests/CoreExtendedNFCTests/TUnionTests.swift @@ -3,7 +3,7 @@ // ## References // - T-Union AID: A000000632010105 // - GET BALANCE: CLA=0x80 INS=0x5C P1=0x00 P2=0x02 → 4 bytes -// - Balance: bits 1-31 as signed integer (CNY fen) +// - Balance: low 31 bits of a 4-byte big-endian CNY fen value // - File 0x15: serial (bytes 10-19), validity (bytes 20-27) @testable import CoreExtendedNFC import Foundation @@ -15,10 +15,10 @@ struct TUnionTests { @Test func `Select T-Union AID and read balance`() async throws { let transport = MockTransport() - // Balance = 5000 fen (50 yuan). Shifted left by 1: 5000 << 1 = 10000 = 0x00002710 + // Balance = 5000 fen (50 yuan). transport.apduResponses = [ ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), // SELECT AID - ResponseAPDU(data: Data([0x00, 0x00, 0x27, 0x10]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 0 + ResponseAPDU(data: Data([0x00, 0x00, 0x13, 0x88]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 0 ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0x00]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 1 ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), // SELECT file 0x15 ResponseAPDU(data: buildFile15Data(), sw1: 0x90, sw2: 0x00), // READ BINARY @@ -49,12 +49,12 @@ struct TUnionTests { // MARK: - Balance Parsing @Test - func `Parse balance with bit shift (bits 1-31)`() async throws { + func `Parse balance as big-endian fen`() async throws { let transport = MockTransport() - // 12345 fen → shifted: 12345 << 1 = 24690 = 0x00006072 + // 12345 fen = 0x00003039 transport.apduResponses = [ ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), // SELECT - ResponseAPDU(data: Data([0x00, 0x00, 0x60, 0x72]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 0 + ResponseAPDU(data: Data([0x00, 0x00, 0x30, 0x39]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 0 ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0x00]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 1 ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), // SELECT file fails (no file info) ] @@ -65,6 +65,11 @@ struct TUnionTests { #expect(result.balanceRaw == 12345) } + @Test + func `Parse balance masks upper garbage bit`() { + #expect(TUnionReader.parseBalanceFen(Data([0x80, 0x00, 0x13, 0x88])) == 5000) + } + @Test func `Zero balance`() async throws { let transport = MockTransport() @@ -93,7 +98,7 @@ struct TUnionTests { let calendar = Calendar(identifier: .gregorian) let components = try calendar.dateComponents( in: #require(TimeZone(identifier: "Asia/Shanghai")), - from: #require(date) + from: #require(date), ) #expect(components.year == 2025) #expect(components.month == 12) @@ -114,7 +119,7 @@ struct TUnionTests { let transport = MockTransport() transport.apduResponses = [ ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), // SELECT AID - ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0x02]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 0: 1 fen + ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0x01]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 0: 1 fen ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0x00]), sw1: 0x90, sw2: 0x00), // GET BALANCE slot 1 ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), // SELECT file ResponseAPDU(data: buildFile15Data(serial: "31234567890123456789"), sw1: 0x90, sw2: 0x00), @@ -126,22 +131,91 @@ struct TUnionTests { // Serial is hex of bytes 10-19, with first nibble skipped // "31234567890123456789" → skip first char → "1234567890123456789" #expect(result.serialNumber == "1234567890123456789") + #expect(transport.sentAPDUs[3].bytes == Data([0x00, 0xA4, 0x00, 0x00, 0x02, 0x00, 0x15, 0x00])) } @Test - func `Final balance subtracts slot 1 from slot 0`() async throws { + func `Display balance uses primary purse when populated`() async throws { let transport = MockTransport() transport.apduResponses = [ ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), - ResponseAPDU(data: Data([0x00, 0x00, 0x27, 0x10]), sw1: 0x90, sw2: 0x00), // 5000 - ResponseAPDU(data: Data([0x00, 0x00, 0x03, 0xE8]), sw1: 0x90, sw2: 0x00), // 500 + ResponseAPDU(data: Data([0x00, 0x00, 0x13, 0x88]), sw1: 0x90, sw2: 0x00), // 5000 + ResponseAPDU(data: Data([0x00, 0x00, 0x03, 0xE8]), sw1: 0x90, sw2: 0x00), // 1000 ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), ] let reader = TUnionReader(transport: transport) let result = try await reader.readBalance() - #expect(result.balanceRaw == 4500) + #expect(result.balanceRaw == 5000) + } + + @Test + func `Display balance falls back to negative purse when primary is zero`() async throws { + let transport = MockTransport() + transport.apduResponses = [ + ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), + ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0x00]), sw1: 0x90, sw2: 0x00), + ResponseAPDU(data: Data([0x00, 0x00, 0x03, 0xE8]), sw1: 0x90, sw2: 0x00), + ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), + ] + + let reader = TUnionReader(transport: transport) + let result = try await reader.readBalance() + + #expect(result.balanceRaw == -1000) + } + + // MARK: - Records + + @Test + func `Read T-Union transaction records from SFI 18`() async throws { + let transport = MockTransport() + transport.apduResponses = [ + ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), + ResponseAPDU(data: Data([0x00, 0x00, 0x02, 0xE4]), sw1: 0x90, sw2: 0x00), + ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0x00]), sw1: 0x90, sw2: 0x00), + ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), + ResponseAPDU(data: buildTransactionRecord(amount: 260, type: 0x06), sw1: 0x90, sw2: 0x00), + ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x83), + ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x83), + ] + + let reader = TUnionReader(transport: transport) + let result = try await reader.readBalance() + + #expect(result.balanceRaw == 740) + #expect(result.transactions.count == 1) + let tx = try #require(result.transactions.first) + #expect(tx.type == .purchase) + #expect(tx.amount == -260) + #expect(tx.recordNumber == 1) + #expect(tx.entryStation == "010203040506") + #expect(tx.metadata["source"] == "TUnion SFI 18") + #expect(transport.sentAPDUs[4].bytes == Data([0x00, 0xB2, 0x01, 0xC4, 0x17])) + } + + @Test + func `Parse top-up transaction record`() throws { + let tx = try #require(TUnionReader.parseTransactionRecord(buildTransactionRecord(amount: 10000, type: 0x02), recordNumber: 3)) + #expect(tx.type == .topup) + #expect(tx.amount == 10000) + #expect(tx.recordNumber == 3) + } + + @Test + func `Parse BCD date time`() throws { + let date = try #require(TUnionReader.parseBCDDateTime(Data([0x20, 0x26, 0x05, 0x09, 0x12, 0x34, 0x56]))) + let components = try Calendar(identifier: .gregorian).dateComponents( + in: #require(TimeZone(identifier: "Asia/Shanghai")), + from: date, + ) + #expect(components.year == 2026) + #expect(components.month == 5) + #expect(components.day == 9) + #expect(components.hour == 12) + #expect(components.minute == 34) + #expect(components.second == 56) } // MARK: - Helpers @@ -163,6 +237,32 @@ struct TUnionTests { return data } + private func buildTransactionRecord(amount: Int, type: UInt8) -> Data { + var data = Data(repeating: 0x00, count: 0x17) + let amountBytes = UInt32(amount).bigEndian + withUnsafeBytes(of: amountBytes) { bytes in + data[5] = bytes[0] + data[6] = bytes[1] + data[7] = bytes[2] + data[8] = bytes[3] + } + data[9] = type + data[10] = 0x01 + data[11] = 0x02 + data[12] = 0x03 + data[13] = 0x04 + data[14] = 0x05 + data[15] = 0x06 + data[16] = 0x20 + data[17] = 0x26 + data[18] = 0x05 + data[19] = 0x09 + data[20] = 0x12 + data[21] = 0x34 + data[22] = 0x56 + return data + } + private func hexToData(_ hex: String) -> Data { var data = Data() var chars = hex.makeIterator() diff --git a/Tests/CoreExtendedNFCTests/TransitBalanceTests.swift b/Tests/CoreExtendedNFCTests/TransitBalanceTests.swift new file mode 100644 index 0000000..9a92d48 --- /dev/null +++ b/Tests/CoreExtendedNFCTests/TransitBalanceTests.swift @@ -0,0 +1,16 @@ +@testable import CoreExtendedNFC +import Testing + +struct TransitBalanceTests { + @Test + func `Formats TWD balance`() { + let balance = TransitBalance( + serialNumber: "", + balanceRaw: 245, + currencyCode: "TWD", + cardName: "EasyCard", + ) + + #expect(balance.formattedBalance == "NT$245") + } +} diff --git a/docs/Transit-Card-Research.md b/docs/Transit-Card-Research.md index ecb1c1a..fb521e1 100644 --- a/docs/Transit-Card-Research.md +++ b/docs/Transit-Card-Research.md @@ -4,6 +4,15 @@ Research date: 2026-05-09 This note captures the CardBal IDA findings, public implementation checks, and the CoreExtendedNFC changes made for the current transit-card balance work. +## Public Reference Sources + +The transit readers are cross-checked against these open-source projects: + +| Project | Local clone | Revision inspected | Useful areas | +| ---------- | --------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| Metrodroid | `/tmp/metrodroid-src` | `04a603ba639f7a270b7bdbf24158c7d601087c29` | EasyCard Classic layout, Octopus date-based offset, T-Union balance bit layout, KSX6924 card support. | +| FareBot | `/tmp/farebot-src` | `dc09f6f014ea3675b64bcd38335b4b78d77fa374` | Octopus offset tests, ISO 7816 transit-card scaffolding, China and KSX6924 card modules. | + ## CardBal IDA Source CardBal was inspected through the local IDA MCP instance: @@ -14,18 +23,18 @@ CardBal was inspected through the local IDA MCP instance: Useful CardBal addresses: -| Area | Address | Finding | -| --- | ---: | --- | -| FeliCa profile table | `0x1002DD064` | Builds FeliCa transit-card profiles. | -| Japan Transit IC balance | `0x1002DD7E4` | Parses service `008B` balance from bytes 11-12, little-endian. | -| Octopus balance | `0x1002DDC08` | Parses service `0117` block 0 first four bytes, big-endian raw value. | -| Octopus offset | `0x1001A1AEC` | Returns raw offset `350` from 2010-12-01, legacy offset `35`. | -| Japan brand mapping | `0x1002DB5D8` | Maps issuer/operator hints such as `JE` to Suica, `JW` to ICOCA, `NR` to Nimoca. | -| Japan activity view | `0x100195994` | Shows `108F` history layout details. | -| T-Union AID gate | `0x1001D6A98` | Checks initial selected AID `A000000632010105`. | -| T-Union primary purse | `0x1001D7318` | Sends `80 5C 00 02`, `Le=04`. | -| T-Union negative purse | `0x1001D794C` | Sends `80 5C 01 02`, `Le=04`. | -| KSX6924 AID set | `0x1001726B8` | Includes Hyundai, T-Money, Cashbee, EB Card, Snapper/MOIBA, and K-Cash AIDs. | +| Area | Address | Finding | +| ------------------------ | ------------: | ------------------------------------------------------------------------------------------------------------------------- | +| FeliCa profile table | `0x1002DD064` | Builds FeliCa transit-card profiles. | +| Japan Transit IC balance | `0x1002DD7E4` | Parses service `008B` balance from bytes 11-12, little-endian. | +| Octopus balance | `0x1002DDC08` | Parses service `0117` block 0 first four bytes, big-endian raw value. | +| Octopus offset | `0x1001A1AEC` | Returns raw offset `350` from 2010-12-01, legacy offset `35`; runtime code follows the public 2017 offset schedule below. | +| Japan brand mapping | `0x1002DB5D8` | Maps issuer/operator hints such as `JE` to Suica, `JW` to ICOCA, `NR` to Nimoca. | +| Japan activity view | `0x100195994` | Shows `108F` history layout details. | +| T-Union AID gate | `0x1001D6A98` | Checks initial selected AID `A000000632010105`. | +| T-Union primary purse | `0x1001D7318` | Sends `80 5C 00 02`, `Le=04`. | +| T-Union negative purse | `0x1001D794C` | Sends `80 5C 01 02`, `Le=04`. | +| KSX6924 AID set | `0x1001726B8` | Includes Hyundai, T-Money, Cashbee, EB Card, Snapper/MOIBA, and K-Cash AIDs. | ## Implemented Fixes @@ -43,17 +52,17 @@ CoreExtendedNFC now reads the balance at offset `0x0B`, matching CardBal. The hi ### Hong Kong Octopus -CardBal and TRETJapanNFCReader agree on the card path: +CardBal, Metrodroid, FareBot, and TRETJapanNFCReader agree on the card path: - FeliCa system code: `8008` - Balance service: `0117`, encoded for CoreNFC as `17 01` - Balance block: block 0 - Raw value: first four bytes, big-endian -- Current offset: `350` -- Legacy offset: `35` -- Formula in HKD: `(raw - offset) / 10` +- Pre-2017 offset: `350` +- Current offset from 2017-10-01: `500` +- Formula in HKD cents: `(raw - offset) * 10` -CoreExtendedNFC now adds `OctopusReader`, dispatches FeliCa system code `8008` to that reader, formats HKD balances, and logs raw block details. The unified `TransitBalance.balanceRaw` value stores cents, so the code uses `(raw - offset) * 10`. +CoreExtendedNFC now adds `OctopusReader`, dispatches FeliCa system code `8008` to that reader, formats HKD balances, and logs raw block details. The unified `TransitBalance.balanceRaw` value stores cents, and the reader chooses the raw offset from the scan date. ### China T-Union, Shenzhen Tong, Nanjing @@ -62,10 +71,11 @@ CardBal confirms the T-Union balance flow: - AID: `A000000632010105` - Primary purse APDU: `80 5C 00 02`, `Le=04` - Negative purse APDU: `80 5C 01 02`, `Le=04` -- Final balance: primary purse minus negative purse +- Balance bytes: low 31 bits of the big-endian 4-byte response +- Final balance: primary purse when populated, otherwise primary purse minus negative purse - Unit: CNY fen -CoreExtendedNFC now reads both purse slots and logs SELECT, each purse APDU response, and the final parsed value. Existing Shenzhen and Nanjing T-Union cards should follow this same AID path when the card exposes the national transport application. +Metrodroid documents the top bit as a spare/garbage bit, so CoreExtendedNFC masks with `0x7FFFFFFF` before displaying the value. CoreExtendedNFC reads both purse slots and logs SELECT, each purse APDU response, and the final parsed value. Existing Shenzhen and Nanjing T-Union cards should follow this same AID path when the card exposes the national transport application. The T-Union AID `A000000632010105` is required in the sample app polling identifiers for CoreNFC ISO 7816 APDU access. The older Shenzhen Tong AID `5041592E535A54` is also present for discovery and logging. Confirmed balance support uses the T-Union AID path above. @@ -82,18 +92,33 @@ CardBal includes the KSX6924-family AID set: CoreExtendedNFC now tries these AIDs in that order and logs SELECT outcomes, balance APDU responses, and record reads. The sample app `Info.plist` mirrors these AIDs so CoreNFC can surface and transceive with those ISO 7816 applications. The balance APDU remains the KSX6924 command `90 4C 00 00`, `Le=04`. +`KSX6924Reader` continues AID probing when a selectable application reports a recoverable SELECT status through either a response status word or an `unexpectedStatusWord` transport error. + +### Taiwan EasyCard + +Metrodroid exposes the old EasyCard Classic dump layout: + +- Card family: MIFARE Classic, keys required +- Magic: sector 0 block 1 equals `0e140001070208030904081000000000` +- Balance: sector 2 block 0, offset 0, 4-byte little-endian TWD amount +- Refill: sector 2 block 2 +- Transactions: sector 3 blocks 1-2, sector 4 blocks 0-2, sector 5 blocks 0-2 +- Time: Taipei timezone, seconds since Unix epoch + +CoreExtendedNFC formats `TWD` balances as `NT$` integer amounts, ready for a Classic dump parser that consumes already-decrypted dump data. + ## Researched Cards -| Card | Protocol reality | Current library handling | -| --- | --- | --- | -| EasyCard | Classic EasyCard is MIFARE Classic. Crypto1 authenticated reads sit outside iOS CoreNFC's public API. | Identification/logging path only. | -| Octopus | FeliCa system `8008`, service `0117`. | Implemented balance reader. | -| T-Union / Shenzhen / Nanjing | ISO 7816 AID `A000000632010105`, dual-purse balance. | Implemented dual-purse balance. | -| Singpass | Singpass is an identity/verification product. Singapore passport reading is eMRTD; CEPAS/EZ-Link uses separate transit-card AIDs. | Existing passport module covers eMRTD. CEPAS AID discovery was added for logs. | -| ICOCA | Japan Transit IC on FeliCa system `0003`. | Implemented via Japan IC reader with corrected offset. | -| Nimoca | Japan Transit IC on FeliCa system `0003`. | Implemented via Japan IC reader with corrected offset. | -| AT HOP | MIFARE DESFire EV1 with locked transit files. Public research exposes serial-level data more readily than balance. | DESFire identification/logging path. | -| Snapper | KSX6924-family card path in public research and CardBal AID table. | Added AID probing through KSX6924 reader. | +| Card | Protocol reality | Current library handling | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| EasyCard | Classic EasyCard is MIFARE Classic. Crypto1 authenticated reads sit outside iOS CoreNFC's public API. | Identification/logging path only. | +| Octopus | FeliCa system `8008`, service `0117`. | Implemented balance reader. | +| T-Union / Shenzhen / Nanjing | ISO 7816 AID `A000000632010105`, dual-purse balance. | Implemented dual-purse balance. | +| Singpass | Singpass is an identity/verification product. Singapore passport reading is eMRTD; CEPAS/EZ-Link uses separate transit-card AIDs. | Existing passport module covers eMRTD. CEPAS AID discovery was added for logs. | +| ICOCA | Japan Transit IC on FeliCa system `0003`. | Implemented via Japan IC reader with corrected offset. | +| Nimoca | Japan Transit IC on FeliCa system `0003`. | Implemented via Japan IC reader with corrected offset. | +| AT HOP | MIFARE DESFire EV1 with locked transit files. Public research exposes serial-level data more readily than balance. | DESFire identification/logging path. | +| Snapper | KSX6924-family card path in public research and CardBal AID table. | Added AID probing through KSX6924 reader. | ## Validation Notes From c0301d0c565b0c68f6aea3fe9e18b586cf190144 Mon Sep 17 00:00:00 2001 From: Lakr Date: Sat, 9 May 2026 01:27:56 +0900 Subject: [PATCH 06/10] Fix Octopus physical card balance offset --- .../Transit/HongKong/OctopusConstants.swift | 24 +++++------ .../Transit/HongKong/OctopusReader.swift | 8 ++-- Tests/CoreExtendedNFCTests/OctopusTests.swift | 36 +++++++++++++--- docs/Transit-Card-Research.md | 41 ++++++++++--------- 4 files changed, 69 insertions(+), 40 deletions(-) diff --git a/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusConstants.swift b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusConstants.swift index c50fc37..cf58e5c 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusConstants.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusConstants.swift @@ -4,8 +4,8 @@ import Foundation /// /// References: /// - TRETJapanNFCReader OctopusCardItemType / OctopusCardData -/// - Metrodroid OctopusData -/// - FareBot OctopusData +/// - Octopus official FAQ for post-2017 HK$50 convenience-limit eligibility +/// - Metrodroid OctopusData and FareBot OctopusData for raw value layout /// - System code 0x8008, balance service 0x0117 enum OctopusConstants { /// Octopus FeliCa system code. @@ -14,17 +14,17 @@ enum OctopusConstants { /// Balance service code 0x0117, encoded little-endian for CoreNFC. static let balanceServiceCode = Data([0x17, 0x01]) - /// Raw offset used for Octopus cards before the 2017 negative-balance change. - static let pre2017BalanceRawOffset = 350 + /// Default raw offset for physical Octopus cards when the issue class is unknown. + static let defaultBalanceRawOffset = 350 - /// Raw offset used for Octopus cards from 2017-10-01 onward. - static let currentBalanceRawOffset = 500 + /// Raw offset for On-Loan Octopus cards issued from 2017-10-01 and mobile Octopus products. + static let expandedConvenienceLimitBalanceRawOffset = 500 - /// Compatibility alias for callers that explicitly request the older offset. - static let legacyBalanceRawOffset = pre2017BalanceRawOffset + /// Compatibility alias for callers that explicitly request the older physical-card offset. + static let legacyBalanceRawOffset = defaultBalanceRawOffset - /// Effective date of the Octopus maximum negative-balance change, in Hong Kong time. - static let currentOffsetStartDate: Date = { + /// Effective issue date for Octopus cards eligible for the HK$50 convenience limit, in Hong Kong time. + static let expandedConvenienceLimitIssueDate: Date = { var components = DateComponents() components.calendar = Calendar(identifier: .gregorian) components.timeZone = TimeZone(identifier: "Asia/Hong_Kong") @@ -34,7 +34,7 @@ enum OctopusConstants { return components.date ?? Date(timeIntervalSince1970: 1_506_787_200) }() - static func balanceRawOffset(for scanDate: Date) -> Int { - scanDate >= currentOffsetStartDate ? currentBalanceRawOffset : pre2017BalanceRawOffset + static func balanceRawOffset(cardIssuedAt issueDate: Date) -> Int { + issueDate >= expandedConvenienceLimitIssueDate ? expandedConvenienceLimitBalanceRawOffset : defaultBalanceRawOffset } } diff --git a/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift index c2e5407..9a08ed2 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift @@ -7,15 +7,15 @@ public struct OctopusReader: Sendable { public init(transport: any FeliCaTagTransporting) { self.transport = transport - balanceOffset = OctopusConstants.balanceRawOffset(for: Date()) + balanceOffset = OctopusConstants.defaultBalanceRawOffset } public init( transport: any FeliCaTagTransporting, - scanDate: Date, + cardIssueDate: Date, ) { self.transport = transport - balanceOffset = OctopusConstants.balanceRawOffset(for: scanDate) + balanceOffset = OctopusConstants.balanceRawOffset(cardIssuedAt: cardIssueDate) } public init( @@ -64,7 +64,7 @@ public struct OctopusReader: Sendable { ) } - static func balanceCents(rawValue: Int, offset: Int = OctopusConstants.currentBalanceRawOffset) -> Int { + static func balanceCents(rawValue: Int, offset: Int = OctopusConstants.defaultBalanceRawOffset) -> Int { (rawValue - offset) * 10 } diff --git a/Tests/CoreExtendedNFCTests/OctopusTests.swift b/Tests/CoreExtendedNFCTests/OctopusTests.swift index 197bc93..fa293ff 100644 --- a/Tests/CoreExtendedNFCTests/OctopusTests.swift +++ b/Tests/CoreExtendedNFCTests/OctopusTests.swift @@ -11,7 +11,19 @@ import Testing struct OctopusTests { @Test - func `Read Octopus balance`() async throws { + func `Read Octopus balance defaults to physical card offset`() async throws { + let transport = octopusTransport(rawValue: 920) + + let result = try await OctopusReader(transport: transport).readBalance() + + #expect(result.balanceRaw == 5700) + #expect(result.currencyCode == "HKD") + #expect(result.cardName == "Octopus") + #expect(result.formattedBalance == "HK$57.00") + } + + @Test + func `Read Octopus balance with expanded convenience limit offset`() async throws { var block = Data(repeating: 0x00, count: 16) block[0] = 0x00 block[1] = 0x00 @@ -26,7 +38,7 @@ struct OctopusTests { let result = try await OctopusReader( transport: transport, - scanDate: date(year: 2026, month: 5, day: 9), + balanceOffset: OctopusConstants.expandedConvenienceLimitBalanceRawOffset, ).readBalance() #expect(result.balanceRaw == 41190) @@ -36,9 +48,9 @@ struct OctopusTests { } @Test - func `Octopus offset follows scan date`() { - #expect(OctopusConstants.balanceRawOffset(for: date(year: 2017, month: 9, day: 30)) == 350) - #expect(OctopusConstants.balanceRawOffset(for: date(year: 2017, month: 10, day: 1)) == 500) + func `Octopus offset follows card issue date`() { + #expect(OctopusConstants.balanceRawOffset(cardIssuedAt: date(year: 2017, month: 9, day: 30)) == 350) + #expect(OctopusConstants.balanceRawOffset(cardIssuedAt: date(year: 2017, month: 10, day: 1)) == 500) } @Test @@ -63,6 +75,20 @@ struct OctopusTests { } } + private func octopusTransport(rawValue: UInt32) -> MockFeliCaServiceTransport { + var block = Data(repeating: 0x00, count: 16) + block[0] = UInt8((rawValue >> 24) & 0xFF) + block[1] = UInt8((rawValue >> 16) & 0xFF) + block[2] = UInt8((rawValue >> 8) & 0xFF) + block[3] = UInt8(rawValue & 0xFF) + + return MockFeliCaServiceTransport( + serviceVersions: [Data([0x17, 0x01]): Data([0x00, 0x10])], + serviceBlocks: [Data([0x17, 0x01]): [block]], + systemCode: Data([0x80, 0x08]), + ) + } + private func date(year: Int, month: Int, day: Int) -> Date { var components = DateComponents() components.calendar = Calendar(identifier: .gregorian) diff --git a/docs/Transit-Card-Research.md b/docs/Transit-Card-Research.md index fb521e1..f53073b 100644 --- a/docs/Transit-Card-Research.md +++ b/docs/Transit-Card-Research.md @@ -8,10 +8,11 @@ This note captures the CardBal IDA findings, public implementation checks, and t The transit readers are cross-checked against these open-source projects: -| Project | Local clone | Revision inspected | Useful areas | -| ---------- | --------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- | -| Metrodroid | `/tmp/metrodroid-src` | `04a603ba639f7a270b7bdbf24158c7d601087c29` | EasyCard Classic layout, Octopus date-based offset, T-Union balance bit layout, KSX6924 card support. | -| FareBot | `/tmp/farebot-src` | `dc09f6f014ea3675b64bcd38335b4b78d77fa374` | Octopus offset tests, ISO 7816 transit-card scaffolding, China and KSX6924 card modules. | +| Project | Local clone | Revision inspected | Useful areas | +| ---------- | --------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------- | +| Metrodroid | `/tmp/metrodroid-src` | `04a603ba639f7a270b7bdbf24158c7d601087c29` | EasyCard Classic layout, Octopus raw layout, T-Union balance bit layout, KSX6924 card support. | +| FareBot | `/tmp/farebot-src` | `dc09f6f014ea3675b64bcd38335b4b78d77fa374` | Octopus raw-layout tests, ISO 7816 transit-card scaffolding, China and KSX6924 card modules. | +| Octopus HK | Public FAQ | Inspected 2026-05-09 | Official HK$35/HK$50 convenience-limit eligibility by card issue class. | ## CardBal IDA Source @@ -23,18 +24,18 @@ CardBal was inspected through the local IDA MCP instance: Useful CardBal addresses: -| Area | Address | Finding | -| ------------------------ | ------------: | ------------------------------------------------------------------------------------------------------------------------- | -| FeliCa profile table | `0x1002DD064` | Builds FeliCa transit-card profiles. | -| Japan Transit IC balance | `0x1002DD7E4` | Parses service `008B` balance from bytes 11-12, little-endian. | -| Octopus balance | `0x1002DDC08` | Parses service `0117` block 0 first four bytes, big-endian raw value. | -| Octopus offset | `0x1001A1AEC` | Returns raw offset `350` from 2010-12-01, legacy offset `35`; runtime code follows the public 2017 offset schedule below. | -| Japan brand mapping | `0x1002DB5D8` | Maps issuer/operator hints such as `JE` to Suica, `JW` to ICOCA, `NR` to Nimoca. | -| Japan activity view | `0x100195994` | Shows `108F` history layout details. | -| T-Union AID gate | `0x1001D6A98` | Checks initial selected AID `A000000632010105`. | -| T-Union primary purse | `0x1001D7318` | Sends `80 5C 00 02`, `Le=04`. | -| T-Union negative purse | `0x1001D794C` | Sends `80 5C 01 02`, `Le=04`. | -| KSX6924 AID set | `0x1001726B8` | Includes Hyundai, T-Money, Cashbee, EB Card, Snapper/MOIBA, and K-Cash AIDs. | +| Area | Address | Finding | +| ------------------------ | ------------: | ----------------------------------------------------------------------------------------------------------------------- | +| FeliCa profile table | `0x1002DD064` | Builds FeliCa transit-card profiles. | +| Japan Transit IC balance | `0x1002DD7E4` | Parses service `008B` balance from bytes 11-12, little-endian. | +| Octopus balance | `0x1002DDC08` | Parses service `0117` block 0 first four bytes, big-endian raw value. | +| Octopus offset | `0x1001A1AEC` | Returns raw offset `350` from 2010-12-01 and legacy offset `35`; this matches old physical-card scans seen in practice. | +| Japan brand mapping | `0x1002DB5D8` | Maps issuer/operator hints such as `JE` to Suica, `JW` to ICOCA, `NR` to Nimoca. | +| Japan activity view | `0x100195994` | Shows `108F` history layout details. | +| T-Union AID gate | `0x1001D6A98` | Checks initial selected AID `A000000632010105`. | +| T-Union primary purse | `0x1001D7318` | Sends `80 5C 00 02`, `Le=04`. | +| T-Union negative purse | `0x1001D794C` | Sends `80 5C 01 02`, `Le=04`. | +| KSX6924 AID set | `0x1001726B8` | Includes Hyundai, T-Money, Cashbee, EB Card, Snapper/MOIBA, and K-Cash AIDs. | ## Implemented Fixes @@ -58,11 +59,13 @@ CardBal, Metrodroid, FareBot, and TRETJapanNFCReader agree on the card path: - Balance service: `0117`, encoded for CoreNFC as `17 01` - Balance block: block 0 - Raw value: first four bytes, big-endian -- Pre-2017 offset: `350` -- Current offset from 2017-10-01: `500` +- Default physical-card offset: `350` +- Expanded HK$50 convenience-limit offset: `500` - Formula in HKD cents: `(raw - offset) * 10` -CoreExtendedNFC now adds `OctopusReader`, dispatches FeliCa system code `8008` to that reader, formats HKD balances, and logs raw block details. The unified `TransitBalance.balanceRaw` value stores cents, and the reader chooses the raw offset from the scan date. +Octopus' official FAQ states the HK$50 convenience limit applies to On-Loan Octopus cards issued on or after 2017-10-01 and mobile Octopus products. Older physical cards retain the HK$35 convenience limit. This makes card issue class the deciding signal for the balance offset. Metrodroid and FareBot use scan time as a proxy, which is useful for synthetic tests and weaker for live physical-card reads. + +CoreExtendedNFC adds `OctopusReader`, dispatches FeliCa system code `8008` to that reader, formats HKD balances, and logs raw block details. The unified `TransitBalance.balanceRaw` value stores cents. The reader defaults to offset `350` because iOS live reads currently expose service `0117` without a reliable issue-class signal. Callers with confirmed post-2017/mobile card context can pass offset `500` explicitly. ### China T-Union, Shenzhen Tong, Nanjing From 322c5e040f3ddf2befa193fc115eae9cf5321f15 Mon Sep 17 00:00:00 2001 From: Lakr Date: Sat, 9 May 2026 01:36:27 +0900 Subject: [PATCH 07/10] Align Octopus offset with Y Mobile --- .../Transit/HongKong/OctopusConstants.swift | 24 +++--------------- .../Transit/HongKong/OctopusReader.swift | 8 ------ Tests/CoreExtendedNFCTests/OctopusTests.swift | 24 +++--------------- docs/Transit-Card-Research.md | 25 ++++++++++++++++--- 4 files changed, 28 insertions(+), 53 deletions(-) diff --git a/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusConstants.swift b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusConstants.swift index cf58e5c..e884bdd 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusConstants.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusConstants.swift @@ -4,8 +4,8 @@ import Foundation /// /// References: /// - TRETJapanNFCReader OctopusCardItemType / OctopusCardData -/// - Octopus official FAQ for post-2017 HK$50 convenience-limit eligibility /// - Metrodroid OctopusData and FareBot OctopusData for raw value layout +/// - CardBal and Y Mobile IDA checks for live FeliCa balance arithmetic /// - System code 0x8008, balance service 0x0117 enum OctopusConstants { /// Octopus FeliCa system code. @@ -14,27 +14,9 @@ enum OctopusConstants { /// Balance service code 0x0117, encoded little-endian for CoreNFC. static let balanceServiceCode = Data([0x17, 0x01]) - /// Default raw offset for physical Octopus cards when the issue class is unknown. + /// Raw offset used by CardBal and Y Mobile for live service 0x0117 balance reads. static let defaultBalanceRawOffset = 350 - /// Raw offset for On-Loan Octopus cards issued from 2017-10-01 and mobile Octopus products. - static let expandedConvenienceLimitBalanceRawOffset = 500 - - /// Compatibility alias for callers that explicitly request the older physical-card offset. + /// Compatibility alias for callers that explicitly name the physical-card offset. static let legacyBalanceRawOffset = defaultBalanceRawOffset - - /// Effective issue date for Octopus cards eligible for the HK$50 convenience limit, in Hong Kong time. - static let expandedConvenienceLimitIssueDate: Date = { - var components = DateComponents() - components.calendar = Calendar(identifier: .gregorian) - components.timeZone = TimeZone(identifier: "Asia/Hong_Kong") - components.year = 2017 - components.month = 10 - components.day = 1 - return components.date ?? Date(timeIntervalSince1970: 1_506_787_200) - }() - - static func balanceRawOffset(cardIssuedAt issueDate: Date) -> Int { - issueDate >= expandedConvenienceLimitIssueDate ? expandedConvenienceLimitBalanceRawOffset : defaultBalanceRawOffset - } } diff --git a/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift index 9a08ed2..74defe4 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift @@ -10,14 +10,6 @@ public struct OctopusReader: Sendable { balanceOffset = OctopusConstants.defaultBalanceRawOffset } - public init( - transport: any FeliCaTagTransporting, - cardIssueDate: Date, - ) { - self.transport = transport - balanceOffset = OctopusConstants.balanceRawOffset(cardIssuedAt: cardIssueDate) - } - public init( transport: any FeliCaTagTransporting, balanceOffset: Int, diff --git a/Tests/CoreExtendedNFCTests/OctopusTests.swift b/Tests/CoreExtendedNFCTests/OctopusTests.swift index fa293ff..cc230fe 100644 --- a/Tests/CoreExtendedNFCTests/OctopusTests.swift +++ b/Tests/CoreExtendedNFCTests/OctopusTests.swift @@ -23,7 +23,7 @@ struct OctopusTests { } @Test - func `Read Octopus balance with expanded convenience limit offset`() async throws { + func `Read Octopus balance uses Y Mobile and CardBal raw offset`() async throws { var block = Data(repeating: 0x00, count: 16) block[0] = 0x00 block[1] = 0x00 @@ -38,19 +38,13 @@ struct OctopusTests { let result = try await OctopusReader( transport: transport, - balanceOffset: OctopusConstants.expandedConvenienceLimitBalanceRawOffset, + balanceOffset: OctopusConstants.defaultBalanceRawOffset, ).readBalance() - #expect(result.balanceRaw == 41190) + #expect(result.balanceRaw == 42690) #expect(result.currencyCode == "HKD") #expect(result.cardName == "Octopus") - #expect(result.formattedBalance == "HK$411.90") - } - - @Test - func `Octopus offset follows card issue date`() { - #expect(OctopusConstants.balanceRawOffset(cardIssuedAt: date(year: 2017, month: 9, day: 30)) == 350) - #expect(OctopusConstants.balanceRawOffset(cardIssuedAt: date(year: 2017, month: 10, day: 1)) == 500) + #expect(result.formattedBalance == "HK$426.90") } @Test @@ -88,14 +82,4 @@ struct OctopusTests { systemCode: Data([0x80, 0x08]), ) } - - private func date(year: Int, month: Int, day: Int) -> Date { - var components = DateComponents() - components.calendar = Calendar(identifier: .gregorian) - components.timeZone = TimeZone(identifier: "Asia/Hong_Kong") - components.year = year - components.month = month - components.day = day - return components.date! - } } diff --git a/docs/Transit-Card-Research.md b/docs/Transit-Card-Research.md index f53073b..c5867bc 100644 --- a/docs/Transit-Card-Research.md +++ b/docs/Transit-Card-Research.md @@ -37,6 +37,24 @@ Useful CardBal addresses: | T-Union negative purse | `0x1001D794C` | Sends `80 5C 01 02`, `Le=04`. | | KSX6924 AID set | `0x1001726B8` | Includes Hyundai, T-Money, Cashbee, EB Card, Snapper/MOIBA, and K-Cash AIDs. | +## Y Mobile IDA Source + +Y Mobile was inspected through the local IDA MCP instance: + +- IDB: `/Users/qaq/Desktop/Payload/Runner.app/Frameworks/App.framework/App.i64` +- MCP endpoint: `http://127.0.0.1:13339/mcp` +- Binary: `App.framework/App` +- Flutter source marker: `package:card_reader_flutter/cardreader/Types/OctopusReader.dart` + +Useful Y Mobile addresses: + +| Area | Address | Finding | +| ----------------------- | ---------: | ------------------------------------------------------------------------ | +| Octopus source marker | `0x437ef0` | Dart AOT string for `OctopusReader.dart`. | +| Octopus command marker | `0x4a6b10` | String `060080080117`, matching FeliCa system `8008` and service `0117`. | +| Octopus balance formula | `0x24A910` | Parses response data, then computes `(raw - 350) / 10` as HKD. | +| Raw offset instruction | `0x24A9C0` | `SUB X1, X0, #0x15E`, where `0x15E` is decimal `350`. | + ## Implemented Fixes ### Japan IC, ICOCA, Nimoca @@ -59,13 +77,12 @@ CardBal, Metrodroid, FareBot, and TRETJapanNFCReader agree on the card path: - Balance service: `0117`, encoded for CoreNFC as `17 01` - Balance block: block 0 - Raw value: first four bytes, big-endian -- Default physical-card offset: `350` -- Expanded HK$50 convenience-limit offset: `500` +- Live-read raw offset: `350` - Formula in HKD cents: `(raw - offset) * 10` -Octopus' official FAQ states the HK$50 convenience limit applies to On-Loan Octopus cards issued on or after 2017-10-01 and mobile Octopus products. Older physical cards retain the HK$35 convenience limit. This makes card issue class the deciding signal for the balance offset. Metrodroid and FareBot use scan time as a proxy, which is useful for synthetic tests and weaker for live physical-card reads. +Octopus' official FAQ states the HK$50 convenience limit applies to On-Loan Octopus cards issued on or after 2017-10-01 and mobile Octopus products. CardBal and Y Mobile both still use raw offset `350` for service `0117` live balance arithmetic. Metrodroid and FareBot use scan time as a `350`/`500` proxy, which is useful for synthetic tests and weaker for live physical-card reads. -CoreExtendedNFC adds `OctopusReader`, dispatches FeliCa system code `8008` to that reader, formats HKD balances, and logs raw block details. The unified `TransitBalance.balanceRaw` value stores cents. The reader defaults to offset `350` because iOS live reads currently expose service `0117` without a reliable issue-class signal. Callers with confirmed post-2017/mobile card context can pass offset `500` explicitly. +CoreExtendedNFC adds `OctopusReader`, dispatches FeliCa system code `8008` to that reader, formats HKD balances, and logs raw block details. The unified `TransitBalance.balanceRaw` value stores cents. The reader uses offset `350`, matching CardBal, Y Mobile, and the physical-card scan where raw `920` displays HK$57.00. ### China T-Union, Shenzhen Tong, Nanjing From 4b483fa4fda82533f513d7d1952bf6f8b99b8738 Mon Sep 17 00:00:00 2001 From: Lakr Date: Sat, 9 May 2026 01:37:31 +0900 Subject: [PATCH 08/10] Document Y Mobile Octopus offset findings --- docs/Transit-Card-Research.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/Transit-Card-Research.md b/docs/Transit-Card-Research.md index c5867bc..5b93534 100644 --- a/docs/Transit-Card-Research.md +++ b/docs/Transit-Card-Research.md @@ -55,6 +55,10 @@ Useful Y Mobile addresses: | Octopus balance formula | `0x24A910` | Parses response data, then computes `(raw - 350) / 10` as HKD. | | Raw offset instruction | `0x24A9C0` | `SUB X1, X0, #0x15E`, where `0x15E` is decimal `350`. | +Y Mobile's Dart AOT code gives the strongest live-read evidence for the current Octopus path. The loaded `Runner` binary is the Flutter shell, while `App.framework/App` contains the Dart AOT snapshot with the Octopus reader strings and balance arithmetic. The balance function at `0x24A910` calls the response parser, subtracts `350`, converts to double, divides by `10`, and stores the resulting HKD value. + +The `500` constant appears elsewhere in the Dart AOT image, including Flutter/runtime-style code and generated object-pool setup. The Octopus balance call chain found from `OctopusReader.dart`, `060080080117`, and the balance formula uses `350`. + ## Implemented Fixes ### Japan IC, ICOCA, Nimoca @@ -84,6 +88,13 @@ Octopus' official FAQ states the HK$50 convenience limit applies to On-Loan Octo CoreExtendedNFC adds `OctopusReader`, dispatches FeliCa system code `8008` to that reader, formats HKD balances, and logs raw block details. The unified `TransitBalance.balanceRaw` value stores cents. The reader uses offset `350`, matching CardBal, Y Mobile, and the physical-card scan where raw `920` displays HK$57.00. +Octopus offset decision: + +- Live service `0117` balance reads use offset `350`. +- Raw `920` yields `(920 - 350) * 10 = 5700` HKD cents, displayed as HK$57.00. +- Applying offset `500` to the same raw value displays HK$42.00, which is HK$15.00 below the observed card balance. +- CoreExtendedNFC keeps a single live-read offset constant until a reliable card-side signal identifies a different raw-value encoding. + ### China T-Union, Shenzhen Tong, Nanjing CardBal confirms the T-Union balance flow: From f8b37a70a3b74c664ec7afd2729f334a58f9263e Mon Sep 17 00:00:00 2001 From: Lakr Date: Sat, 9 May 2026 01:51:33 +0900 Subject: [PATCH 09/10] T-Union: use purse delta, SFI read fallback Compute T-Union balance as primary minus negative purse and add robust file-info parsing and metadata. Added file15SFIReadP1 constant and a READ BINARY-by-SFI fallback (p1=0x95) when SELECT 0015 fails; introduced parseFileInfo and readFileInfoFromSFI helpers and record file-info source. TransitBalance now carries reader-specific metadata. Updated display logic to always use primary - negative, and updated TUnionReader to include metadata about balance sources and file info. Tests expanded/added for T-Union, JapanIC and Octopus (with logged-transport helpers and hex utilities). Documentation updated with Shanghai app findings and the new SFI fallback and purse-delta behavior. --- .../Cards/Transit/China/TUnionConstants.swift | 1 + .../Cards/Transit/China/TUnionReader.swift | 70 ++++--- .../Cards/Transit/TransitBalance.swift | 4 + Tests/CoreExtendedNFCTests/JapanICTests.swift | 124 +++++++++++++ Tests/CoreExtendedNFCTests/OctopusTests.swift | 29 +++ Tests/CoreExtendedNFCTests/TUnionTests.swift | 172 +++++++++++++++++- docs/Transit-Card-Research.md | 12 +- 7 files changed, 385 insertions(+), 27 deletions(-) diff --git a/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionConstants.swift b/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionConstants.swift index 7b40c47..0d44dd5 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionConstants.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionConstants.swift @@ -19,6 +19,7 @@ enum TUnionConstants { /// Main file containing serial and validity info. static let balanceFileID = Data([0x00, 0x15]) + static let file15SFIReadP1: UInt8 = 0x95 /// T-Union transaction record SFI. CardBal documents this as a circular /// 0x17-byte transaction/top-up record file. diff --git a/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionReader.swift b/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionReader.swift index dd6fd11..5744a5a 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionReader.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionReader.swift @@ -35,9 +35,8 @@ public struct TUnionReader: Sendable { } NFCLog.debug("T-Union SELECT AID ok data=\(selectResponse.data.hexString)", source: "TUnion") - // 2. GET BALANCE. CardBal reads both purse slots. The card preview uses - // slot 0 when it is populated, and falls back to slot0 - slot1 for cards - // that encode the primary purse as zero. + // 2. GET BALANCE. T-Union exposes two purse registers; the displayed + // value is the signed delta between the primary and negative purse. let balance0 = try await readBalanceSlot(p1: TUnionConstants.GET_BALANCE_P1) let balance1 = await readOptionalBalanceSlot(p1: TUnionConstants.GET_NEGATIVE_BALANCE_P1) let balanceFen = Self.displayBalance(primary: balance0, negative: balance1) @@ -55,6 +54,12 @@ public struct TUnionReader: Sendable { validFrom: fileInfo.validFrom, validUntil: fileInfo.validUntil, transactions: transactions, + metadata: [ + "balanceSource": "TUnion dual purse", + "primaryPurseFen": "\(balance0)", + "negativePurseFen": "\(balance1)", + "fileInfoSource": fileInfo.source, + ], ) } @@ -64,6 +69,7 @@ public struct TUnionReader: Sendable { let serial: String let validFrom: Date? let validUntil: Date? + let source: String } private func readBalanceSlot(p1: UInt8) async throws -> Int { @@ -104,7 +110,7 @@ public struct TUnionReader: Sendable { } static func displayBalance(primary: Int, negative: Int) -> Int { - primary > 0 ? primary : primary - negative + primary - negative } private func readFileInfo() async -> FileInfo { @@ -115,7 +121,7 @@ public struct TUnionReader: Sendable { ) NFCLog.debug("T-Union SELECT file 0015 sw=\(String(format: "%02X%02X", selectFile.sw1, selectFile.sw2)) data=\(selectFile.data.hexString)", source: "TUnion") guard selectFile.isSuccess else { - return FileInfo(serial: "", validFrom: nil, validUntil: nil) + return await readFileInfoFromSFI() } // READ BINARY: need at least 28 bytes (offset 0, covers through validity dates) @@ -124,26 +130,12 @@ public struct TUnionReader: Sendable { ) NFCLog.debug("T-Union READ BINARY 0015 sw=\(String(format: "%02X%02X", readResponse.sw1, readResponse.sw2)) data=\(readResponse.data.hexString)", source: "TUnion") guard readResponse.isSuccess, readResponse.data.count >= 28 else { - return FileInfo(serial: "", validFrom: nil, validUntil: nil) + return await readFileInfoFromSFI() } - let fileData = readResponse.data - - // Serial: bytes 10-19, skip first nibble (convention from Metrodroid) - let serialData = Data(fileData[TUnionConstants.serialOffset ..< TUnionConstants.serialOffset + TUnionConstants.serialLength]) - let serialHex = serialData.hexString - let serial = String(serialHex.dropFirst()) // skip first nibble - - // Validity dates: 4 bytes hex YYYYMMDD - let validFromData = Data(fileData[TUnionConstants.validFromOffset ..< TUnionConstants.validFromOffset + TUnionConstants.validFromLength]) - let validFrom = Self.parseHexDate(validFromData) - - let validUntilData = Data(fileData[TUnionConstants.validUntilOffset ..< TUnionConstants.validUntilOffset + TUnionConstants.validUntilLength]) - let validUntil = Self.parseHexDate(validUntilData) - - return FileInfo(serial: serial, validFrom: validFrom, validUntil: validUntil) + return Self.parseFileInfo(readResponse.data, source: "SELECT 0015 + READ BINARY") } catch { - return FileInfo(serial: "", validFrom: nil, validUntil: nil) + return await readFileInfoFromSFI() } } @@ -151,6 +143,40 @@ public struct TUnionReader: Sendable { CommandAPDU(cla: 0x00, ins: 0xA4, p1: 0x00, p2: 0x00, data: id, le: 0x00) } + private func readFileInfoFromSFI() async -> FileInfo { + do { + let response = try await transport.sendAPDUWithChaining(Self.readFile15BySFIAPDU()) + NFCLog.debug("T-Union READ BINARY SFI 15 sw=\(String(format: "%02X%02X", response.sw1, response.sw2)) data=\(response.data.hexString)", source: "TUnion") + guard response.isSuccess, response.data.count >= 28 else { + return FileInfo(serial: "", validFrom: nil, validUntil: nil, source: "unavailable") + } + return Self.parseFileInfo(response.data, source: "SFI 15 READ BINARY") + } catch { + NFCLog.debug("T-Union READ BINARY SFI 15 skipped: \(error.localizedDescription)", source: "TUnion") + return FileInfo(serial: "", validFrom: nil, validUntil: nil, source: "unavailable") + } + } + + private static func readFile15BySFIAPDU() -> CommandAPDU { + CommandAPDU(cla: 0x00, ins: 0xB0, p1: TUnionConstants.file15SFIReadP1, p2: 0x00, le: 0x00) + } + + private static func parseFileInfo(_ fileData: Data, source: String) -> FileInfo { + // Serial: bytes 10-19, skip first nibble (convention from Metrodroid) + let serialData = Data(fileData[TUnionConstants.serialOffset ..< TUnionConstants.serialOffset + TUnionConstants.serialLength]) + let serialHex = serialData.hexString + let serial = String(serialHex.dropFirst()) // skip first nibble + + // Validity dates: 4 bytes hex YYYYMMDD + let validFromData = Data(fileData[TUnionConstants.validFromOffset ..< TUnionConstants.validFromOffset + TUnionConstants.validFromLength]) + let validFrom = Self.parseHexDate(validFromData) + + let validUntilData = Data(fileData[TUnionConstants.validUntilOffset ..< TUnionConstants.validUntilOffset + TUnionConstants.validUntilLength]) + let validUntil = Self.parseHexDate(validUntilData) + + return FileInfo(serial: serial, validFrom: validFrom, validUntil: validUntil, source: source) + } + private static func readRecordAPDU(sfi: UInt8, recordNumber: UInt8, length: UInt8) -> CommandAPDU { CommandAPDU( cla: 0x00, diff --git a/Sources/CoreExtendedNFC/Cards/Transit/TransitBalance.swift b/Sources/CoreExtendedNFC/Cards/Transit/TransitBalance.swift index 798185b..df5cab1 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/TransitBalance.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/TransitBalance.swift @@ -16,6 +16,8 @@ public struct TransitBalance: Sendable, Equatable, Codable { public let validUntil: Date? /// Recent transaction history (newest first). public let transactions: [TransitTransaction] + /// Reader-specific decoded fields. + public let metadata: [String: String] public init( serialNumber: String, @@ -25,6 +27,7 @@ public struct TransitBalance: Sendable, Equatable, Codable { validFrom: Date? = nil, validUntil: Date? = nil, transactions: [TransitTransaction] = [], + metadata: [String: String] = [:], ) { self.serialNumber = serialNumber self.balanceRaw = balanceRaw @@ -33,6 +36,7 @@ public struct TransitBalance: Sendable, Equatable, Codable { self.validFrom = validFrom self.validUntil = validUntil self.transactions = transactions + self.metadata = metadata } /// Human-readable formatted balance. diff --git a/Tests/CoreExtendedNFCTests/JapanICTests.swift b/Tests/CoreExtendedNFCTests/JapanICTests.swift index 7f8b26c..e1b7d78 100644 --- a/Tests/CoreExtendedNFCTests/JapanICTests.swift +++ b/Tests/CoreExtendedNFCTests/JapanICTests.swift @@ -80,6 +80,100 @@ struct JapanICTests { #expect(tx.exitStation == "0456") } + @Test + func `Logged Japan IC card replays twenty history blocks`() async throws { + let transport = loggedJapanICTransport( + identifier: "010103126B20DB14", + balanceBlock: "00000000000000002000001B0000006F", + historyBlocks: [ + "C746000030B59825DCAD1B0000006F00", + "1601000230B563237024A80000006E00", + "1601000230B501E66323740200006C00", + "1601001730B5D470FABBA80500006AA0", + "050F000F30B40E38011F7808000068A0", + "1601000230B401FB01F84A0900006700", + "1601000230B401E601FBF40900006500", + "C746000030B44F05E0AB2E0C00006300", + "1601000230B381248117C70D000062A0", + "1601000230B3812A8123E90E000060A0", + "1601000230B363110C0CD90F00005E00", + "1601000230B363136311191100005C00", + "1601000230B363116313A51100005A00", + "1601000230B301E86311311200005800", + "1601000230B38117811C1114000056A0", + "0802000030B381170000011500005480", + "1601000230B2812A81177901000053A0", + "C846000030B2A5C869709B0200005100", + "1601000230B2811C81288B03000050A0", + "1601000230B28117811C7B0400004EA0", + ] + ) + + let result = try await JapanICReader(transport: transport).readBalanceAndHistory() + + #expect(result.serialNumber == "010103126B20DB14") + #expect(result.balanceRaw == 27) + #expect(result.transactions.count == 20) + + let first = try #require(result.transactions.first) + #expect(first.type == .purchase) + #expect(first.balanceAfter == 27) + #expect(first.entryStation == "9825") + #expect(first.exitStation == "DCAD") + + let topup = result.transactions[15] + #expect(topup.type == .topup) + #expect(topup.balanceAfter == 5377) + #expect(topup.entryStation == "8117") + } + + @Test + func `Logged low-balance Japan IC card preserves sparse history blocks`() async throws { + let transport = loggedJapanICTransport( + identifier: "0101011278224813", + balanceBlock: "00000000000000003000000A00000006", + historyBlocks: [ + "C746000030B59825DCAD0A0000000600", + "1601000230AE01CC0B02C80000000500", + "1601000230AE0B0201CC5E0100000300", + "0807000030AE0B020000F40100000100", + "00000080000000000000000000000000", + "00000000000000000000000000000000", + "00000080000000000000000000000000", + "00000000000000000000000000000000", + "00000080000000000000000000000000", + "00000000000000000000000000000000", + "00000080000000000000000000000000", + "00000000000000000000000000000000", + "00000080000000000000000000000000", + "00000000000000000000000000000000", + "00000080000000000000000000000000", + "00000000000000000000000000000000", + "00000080000000000000000000000000", + "00000000000000000000000000000000", + "00000080000000000000000000000000", + "00000000000000000000000000000000", + ] + ) + + let result = try await JapanICReader(transport: transport).readBalanceAndHistory() + + #expect(result.serialNumber == "0101011278224813") + #expect(result.balanceRaw == 10) + #expect(result.transactions.count == 12) + + let first = try #require(result.transactions.first) + #expect(first.type == .purchase) + #expect(first.balanceAfter == 10) + #expect(first.entryStation == "9825") + #expect(first.exitStation == "DCAD") + + let sparse = result.transactions[4] + #expect(sparse.type == .trip) + #expect(sparse.balanceAfter == 0) + #expect(sparse.entryStation == "0000") + } + @Test func `System code mismatch throws error`() async { let transport = MockFeliCaServiceTransport( @@ -196,4 +290,34 @@ struct JapanICTests { let result = try await reader.readBalance() #expect(result.balanceRaw == 0) } + + private func loggedJapanICTransport( + identifier: String, + balanceBlock: String, + historyBlocks: [String] + ) -> MockFeliCaServiceTransport { + MockFeliCaServiceTransport( + serviceVersions: [ + JapanICConstants.balanceServiceCode: Data([0x03, 0x00]), + JapanICConstants.historyServiceCode: Data([0x03, 0x00]), + ], + serviceBlocks: [ + JapanICConstants.balanceServiceCode: [hexToData(balanceBlock)], + JapanICConstants.historyServiceCode: historyBlocks.map(hexToData), + ], + systemCode: JapanICConstants.systemCode, + identifier: hexToData(identifier), + ) + } + + private func hexToData(_ hex: String) -> Data { + var data = Data() + var chars = hex.makeIterator() + while let c1 = chars.next(), let c2 = chars.next() { + if let byte = UInt8(String([c1, c2]), radix: 16) { + data.append(byte) + } + } + return data + } } diff --git a/Tests/CoreExtendedNFCTests/OctopusTests.swift b/Tests/CoreExtendedNFCTests/OctopusTests.swift index cc230fe..f18c7be 100644 --- a/Tests/CoreExtendedNFCTests/OctopusTests.swift +++ b/Tests/CoreExtendedNFCTests/OctopusTests.swift @@ -22,6 +22,24 @@ struct OctopusTests { #expect(result.formattedBalance == "HK$57.00") } + @Test + func `Logged Octopus card replays physical card balance block`() async throws { + let transport = MockFeliCaServiceTransport( + serviceVersions: [Data([0x17, 0x01]): Data([0x07, 0x00])], + serviceBlocks: [ + Data([0x17, 0x01]): [hexToData("00000398000000000000000000000003")], + ], + systemCode: Data([0x80, 0x08]), + identifier: hexToData("010107015823C200"), + ) + + let result = try await OctopusReader(transport: transport).readBalance() + + #expect(result.serialNumber == "010107015823C200") + #expect(result.balanceRaw == 5700) + #expect(result.formattedBalance == "HK$57.00") + } + @Test func `Read Octopus balance uses Y Mobile and CardBal raw offset`() async throws { var block = Data(repeating: 0x00, count: 16) @@ -82,4 +100,15 @@ struct OctopusTests { systemCode: Data([0x80, 0x08]), ) } + + private func hexToData(_ hex: String) -> Data { + var data = Data() + var chars = hex.makeIterator() + while let c1 = chars.next(), let c2 = chars.next() { + if let byte = UInt8(String([c1, c2]), radix: 16) { + data.append(byte) + } + } + return data + } } diff --git a/Tests/CoreExtendedNFCTests/TUnionTests.swift b/Tests/CoreExtendedNFCTests/TUnionTests.swift index a672576..e2db91e 100644 --- a/Tests/CoreExtendedNFCTests/TUnionTests.swift +++ b/Tests/CoreExtendedNFCTests/TUnionTests.swift @@ -135,7 +135,7 @@ struct TUnionTests { } @Test - func `Display balance uses primary purse when populated`() async throws { + func `Display balance subtracts negative purse`() async throws { let transport = MockTransport() transport.apduResponses = [ ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), @@ -147,11 +147,11 @@ struct TUnionTests { let reader = TUnionReader(transport: transport) let result = try await reader.readBalance() - #expect(result.balanceRaw == 5000) + #expect(result.balanceRaw == 4000) } @Test - func `Display balance falls back to negative purse when primary is zero`() async throws { + func `Display balance can be negative`() async throws { let transport = MockTransport() transport.apduResponses = [ ResponseAPDU(data: Data(), sw1: 0x90, sw2: 0x00), @@ -166,6 +166,137 @@ struct TUnionTests { #expect(result.balanceRaw == -1000) } + @Test + func `Shanghai public transit card uses purse delta`() async throws { + let transport = MockTransport() + transport.apduResponses = [ + ResponseAPDU( + data: hexToData("6F318408A000000632010105A5259F0801029F0C1E02002900FFFFFFFF02010310477004006744280220200115204012310114"), + sw1: 0x90, + sw2: 0x00 + ), + ResponseAPDU(data: Data([0x00, 0x00, 0x03, 0x20]), sw1: 0x90, sw2: 0x00), + ResponseAPDU(data: Data([0x00, 0x00, 0x03, 0x20]), sw1: 0x90, sw2: 0x00), + ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x86), + ResponseAPDU(data: buildFile15Data(serial: "31234567890123456789"), sw1: 0x90, sw2: 0x00), + ] + + let reader = TUnionReader(transport: transport) + let result = try await reader.readBalance() + + #expect(result.balanceRaw == 0) + #expect(result.formattedBalance == "¥0.00") + #expect(result.serialNumber == "1234567890123456789") + #expect(result.metadata["balanceSource"] == "TUnion dual purse") + #expect(result.metadata["primaryPurseFen"] == "800") + #expect(result.metadata["negativePurseFen"] == "800") + #expect(result.metadata["fileInfoSource"] == "SFI 15 READ BINARY") + #expect(transport.sentAPDUs[4].bytes == Data([0x00, 0xB0, 0x95, 0x00, 0x00])) + } + + @Test + func `Logged Shanghai T-Union card replays balance and transactions`() async throws { + let transport = loggedTUnionTransport( + selectFCI: "6F318408A000000632010105A5259F0801029F0C1E02002900FFFFFFFF02010310477004006744280220200115204012310114", + balance0: "00000320", + balance1: "00000320", + sfi18: [ + "0002000000000000000220009999004020200115151321", + "0001000000000000000220009299004020200115151321", + ] + ) + + let result = try await TUnionReader(transport: transport).readBalance() + + #expect(result.balanceRaw == 0) + #expect(result.transactions.count == 2) + let first = try #require(result.transactions.first) + #expect(first.type == .topup) + #expect(first.amount == 0) + #expect(first.entryStation == "200099990040") + #expect(first.rawData.hexString == "0002000000000000000220009999004020200115151321") + } + + @Test + func `Logged Beijing T-Union card replays balance and ten transactions`() async throws { + let transport = loggedTUnionTransport( + selectFCI: "6F318408A000000632010105A5259F0801029F0C1E00083010FFFFFFFF02010310483001000641250720220301204912310000", + balance0: "000002E4", + balance1: "00000000", + sfi18: [ + "00B1000000000000000941310062938020240310211508", + "00B0000000000000A00941310062938820240310195719", + "00AF0000000000017C0941310040552720240308200056", + "00AE000000000000000941310043483620240308192014", + "00AD000000000000BE0941310043484320240308171554", + "00AC000000000000000941310043501520240308170331", + "00AB000000000001DB0941310043502920240308140501", + "00AA000000000000000941310004150620240308131356", + "00A9000000000000BE0941310040552720240223210850", + "00A8000000000000000941310064192120240223205019", + ] + ) + + let result = try await TUnionReader(transport: transport).readBalance() + + #expect(result.balanceRaw == 740) + #expect(result.formattedBalance == "¥7.40") + #expect(result.transactions.count == 10) + let first = try #require(result.transactions.first) + #expect(first.recordNumber == 1) + #expect(first.entryStation == "413100629380") + #expect(first.rawData.hexString == "00B1000000000000000941310062938020240310211508") + + let second = try #require(result.transactions.dropFirst().first) + #expect(second.amount == -160) + #expect(second.entryStation == "413100629388") + } + + @Test + func `Logged zero-balance T-Union card keeps empty records empty`() async throws { + let transport = loggedTUnionTransport( + selectFCI: "6F368408A000000632010105A5269F080200309F0C1E02215840FFFFFFFF0201031048704040106424182024110720541107000000000000", + balance0: "00000000", + balance1: "00000000", + sfi18: Array(repeating: "0000000000000000000000000000000000000000000000", count: 10) + ) + + let result = try await TUnionReader(transport: transport).readBalance() + + #expect(result.balanceRaw == 0) + #expect(result.transactions.isEmpty) + } + + @Test + func `Logged Shenzhen T-Union card replays stored balance and station data`() async throws { + let transport = loggedTUnionTransport( + selectFCI: "6F318408A000000632010105A5259F0801029F0C1E00083010FFFFFFFF02010310483001000732696420230828209912310000", + balance0: "000000FA", + balance1: "00000000", + sfi18: [ + "0057000000000002580941310192596920240614142250", + "0056000000000000000941310614805320240614133122", + "0055000000000000BE0941310043546620240613120710", + "0054000000000000000941310040553220240613115300", + "0053000000000000000930100500319220240612180513", + "0052000000000000A00941310062938120240612162245", + "0051000000000000BE0941310040552120240611181322", + "0050000000000000000941310004193920240611180136", + "004F000000000000BE0941310004156920240604195201", + "004E000000000000000941310004150620240604194012", + ] + ) + + let result = try await TUnionReader(transport: transport).readBalance() + + #expect(result.balanceRaw == 250) + #expect(result.transactions.count == 10) + let first = try #require(result.transactions.first) + #expect(first.amount == -600) + #expect(first.entryStation == "413101925969") + #expect(first.rawData.hexString == "0057000000000002580941310192596920240614142250") + } + // MARK: - Records @Test @@ -176,6 +307,7 @@ struct TUnionTests { ResponseAPDU(data: Data([0x00, 0x00, 0x02, 0xE4]), sw1: 0x90, sw2: 0x00), ResponseAPDU(data: Data([0x00, 0x00, 0x00, 0x00]), sw1: 0x90, sw2: 0x00), ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), + ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), ResponseAPDU(data: buildTransactionRecord(amount: 260, type: 0x06), sw1: 0x90, sw2: 0x00), ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x83), ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x83), @@ -263,6 +395,40 @@ struct TUnionTests { return data } + private func loggedTUnionTransport( + selectFCI: String, + balance0: String, + balance1: String, + sfi18: [String], + sfi1E: [String] = [] + ) -> MockTransport { + let transport = MockTransport() + var responses: [ResponseAPDU] = [ + ResponseAPDU(data: hexToData(selectFCI), sw1: 0x90, sw2: 0x00), + ResponseAPDU(data: hexToData(balance0), sw1: 0x90, sw2: 0x00), + ResponseAPDU(data: hexToData(balance1), sw1: 0x90, sw2: 0x00), + ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x86), + ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x82), + ] + + responses.append(contentsOf: sfi18.map { + ResponseAPDU(data: hexToData($0), sw1: 0x90, sw2: 0x00) + }) + if sfi18.count < TUnionConstants.maxTransactionRecords { + responses.append(ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x83)) + } + + responses.append(contentsOf: sfi1E.map { + ResponseAPDU(data: hexToData($0), sw1: 0x90, sw2: 0x00) + }) + if sfi1E.count < TUnionConstants.maxTransitActivityRecords { + responses.append(ResponseAPDU(data: Data(), sw1: 0x6A, sw2: 0x83)) + } + + transport.apduResponses = responses + return transport + } + private func hexToData(_ hex: String) -> Data { var data = Data() var chars = hex.makeIterator() diff --git a/docs/Transit-Card-Research.md b/docs/Transit-Card-Research.md index 5b93534..f9ec7e4 100644 --- a/docs/Transit-Card-Research.md +++ b/docs/Transit-Card-Research.md @@ -103,10 +103,18 @@ CardBal confirms the T-Union balance flow: - Primary purse APDU: `80 5C 00 02`, `Le=04` - Negative purse APDU: `80 5C 01 02`, `Le=04` - Balance bytes: low 31 bits of the big-endian 4-byte response -- Final balance: primary purse when populated, otherwise primary purse minus negative purse +- Final balance: primary purse minus negative purse - Unit: CNY fen -Metrodroid documents the top bit as a spare/garbage bit, so CoreExtendedNFC masks with `0x7FFFFFFF` before displaying the value. CoreExtendedNFC reads both purse slots and logs SELECT, each purse APDU response, and the final parsed value. Existing Shenzhen and Nanjing T-Union cards should follow this same AID path when the card exposes the national transport application. +Shanghai Public Transportation Card official app IDA findings: + +- Binary: `/Users/qaq/Desktop/Payload/上海交通卡.app/上海交通卡`, SHA-256 `67cd64bbc104382c727c086de4c567a7fc39ad8a4291e8e49ce3fced9a211c6f` +- `JYNFCManager` drives ISO 7816 NFC reads through `NFCISO7816APDU(initWithData:)` and `sendCommandAPDU:completionHandler:`. +- `sub_1008DDE0C` sends `00A4040008A000000632010105`, selecting the T-Union AID. +- `sub_1008DE030` and related card-info paths send `00B0950000`, a READ BINARY command for SFI `0x15`. +- `sub_1008DED78` parses one balance response by taking `data[0..<4]` as the primary value and `data[6..<9]` as the negative value, converting both from hex-coded decimal text, then storing `primary - negative` through `setBalance:`. + +Metrodroid documents the top bit as a spare/garbage bit, so CoreExtendedNFC masks with `0x7FFFFFFF` before displaying the value. CoreExtendedNFC reads both purse slots and logs SELECT, each purse APDU response, and the final parsed value. A confirmed Shanghai Public Transportation Card returned `00000320` from both purse slots and has a displayed balance of CNY 0.00, which validates the purse-delta formula. For file `0x15` metadata, CoreExtendedNFC first tries `SELECT 0015` plus READ BINARY and then falls back to the official app's `00B0950000` SFI direct-read path when the explicit file SELECT fails. Existing Shenzhen and Nanjing T-Union cards should follow this same AID path when the card exposes the national transport application. The T-Union AID `A000000632010105` is required in the sample app polling identifiers for CoreNFC ISO 7816 APDU access. The older Shenzhen Tong AID `5041592E535A54` is also present for discovery and logging. Confirmed balance support uses the T-Union AID path above. From 29ba537d2ec70de3a4bdfd6899e2ccaef5983e89 Mon Sep 17 00:00:00 2001 From: Lakr Date: Sat, 9 May 2026 01:56:08 +0900 Subject: [PATCH 10/10] Format transit card changes --- AGENTS.md | 1 + .../Cards/Transit/China/TUnionReader.swift | 26 +++++++++---------- .../Transit/HongKong/OctopusReader.swift | 12 ++++----- .../Cards/Transit/Japan/JapanICReader.swift | 16 ++++++------ .../Cards/Transit/Korea/KSX6924Reader.swift | 10 +++---- .../Cards/Transit/TransitBalance.swift | 4 +-- Tests/CoreExtendedNFCTests/JapanICTests.swift | 14 +++++----- Tests/CoreExtendedNFCTests/KSX6924Tests.swift | 2 +- Tests/CoreExtendedNFCTests/OctopusTests.swift | 12 ++++----- Tests/CoreExtendedNFCTests/TUnionTests.swift | 12 ++++----- .../TransitBalanceTests.swift | 2 +- docs/NFC-InfoPlist-Reference.md | 4 +-- 12 files changed, 58 insertions(+), 57 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7a4f099..99c23b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,6 +149,7 @@ NSLayoutConstraint.activate([...]) ### Empty States List view controllers (Scanner, Dump, NDEF, Passport) use `EmptyStateView` for empty-list placeholders: + - Installed as a subview of the table view via `emptyState.install(on: tableView)`. - Visibility toggled by checking the data store's `records.isEmpty`. - Fades on scroll via `emptyState.updateAlpha(for: scrollView)` in `scrollViewDidScroll`. diff --git a/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionReader.swift b/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionReader.swift index 5744a5a..91f1a21 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionReader.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/China/TUnionReader.swift @@ -28,7 +28,7 @@ public struct TUnionReader: Sendable { NFCLog.info("T-Union balance read start", source: "TUnion") // 1. SELECT T-Union AID let selectResponse = try await transport.sendAPDUWithChaining( - CommandAPDU.select(aid: TUnionConstants.tUnionAID), + CommandAPDU.select(aid: TUnionConstants.tUnionAID) ) guard selectResponse.isSuccess else { throw NFCError.unsupportedOperation("T-Union AID not found on this card") @@ -59,7 +59,7 @@ public struct TUnionReader: Sendable { "primaryPurseFen": "\(balance0)", "negativePurseFen": "\(balance1)", "fileInfoSource": fileInfo.source, - ], + ] ) } @@ -76,7 +76,7 @@ public struct TUnionReader: Sendable { let response = try await transport.sendAPDUWithChaining(Self.balanceAPDU(p1: p1)) NFCLog.debug( "T-Union GET BALANCE p1=\(String(format: "%02X", p1)) sw=\(String(format: "%02X%02X", response.sw1, response.sw2)) data=\(response.data.hexString)", - source: "TUnion", + source: "TUnion" ) guard response.isSuccess, response.data.count >= 4 else { throw NFCError.unexpectedStatusWord(response.sw1, response.sw2) @@ -100,7 +100,7 @@ public struct TUnionReader: Sendable { ins: TUnionConstants.GET_BALANCE_INS, p1: p1, p2: TUnionConstants.GET_BALANCE_P2, - le: TUnionConstants.GET_BALANCE_LE, + le: TUnionConstants.GET_BALANCE_LE ) } @@ -117,7 +117,7 @@ public struct TUnionReader: Sendable { do { // SELECT file 0x15 let selectFile = try await transport.sendAPDUWithChaining( - Self.selectFileAPDU(id: TUnionConstants.balanceFileID), + Self.selectFileAPDU(id: TUnionConstants.balanceFileID) ) NFCLog.debug("T-Union SELECT file 0015 sw=\(String(format: "%02X%02X", selectFile.sw1, selectFile.sw2)) data=\(selectFile.data.hexString)", source: "TUnion") guard selectFile.isSuccess else { @@ -126,7 +126,7 @@ public struct TUnionReader: Sendable { // READ BINARY: need at least 28 bytes (offset 0, covers through validity dates) let readResponse = try await transport.sendAPDUWithChaining( - CommandAPDU.readBinary(offset: 0, length: 30), + CommandAPDU.readBinary(offset: 0, length: 30) ) NFCLog.debug("T-Union READ BINARY 0015 sw=\(String(format: "%02X%02X", readResponse.sw1, readResponse.sw2)) data=\(readResponse.data.hexString)", source: "TUnion") guard readResponse.isSuccess, readResponse.data.count >= 28 else { @@ -183,7 +183,7 @@ public struct TUnionReader: Sendable { ins: 0xB2, p1: recordNumber, p2: (sfi << 3) | 0x04, - le: length, + le: length ) } @@ -192,13 +192,13 @@ public struct TUnionReader: Sendable { sfi: TUnionConstants.transactionSFI, length: TUnionConstants.transactionRecordLength, maxRecords: TUnionConstants.maxTransactionRecords, - parser: Self.parseTransactionRecord, + parser: Self.parseTransactionRecord ) let trips = await readRecords( sfi: TUnionConstants.transitActivitySFI, length: TUnionConstants.transitActivityRecordLength, maxRecords: TUnionConstants.maxTransitActivityRecords, - parser: Self.parseTransitActivityRecord, + parser: Self.parseTransitActivityRecord ) let merged = (transactions + trips).sorted { lhs, rhs in switch (lhs.date, rhs.date) { @@ -220,7 +220,7 @@ public struct TUnionReader: Sendable { sfi: UInt8, length: UInt8, maxRecords: Int, - parser: (Data, Int) -> TransitTransaction?, + parser: (Data, Int) -> TransitTransaction? ) async -> [TransitTransaction] { var records: [TransitTransaction] = [] for recordNumber in 1 ... maxRecords { @@ -229,7 +229,7 @@ public struct TUnionReader: Sendable { let response = try await transport.sendAPDUWithChaining(apdu) NFCLog.debug( "T-Union READ RECORD sfi=\(String(format: "%02X", sfi)) record=\(recordNumber) sw=\(String(format: "%02X%02X", response.sw1, response.sw2)) data=\(response.data.hexString)", - source: "TUnion", + source: "TUnion" ) guard response.isSuccess else { if response.statusWord == 0x6A83 || response.statusWord == 0x6A82 { @@ -276,7 +276,7 @@ public struct TUnionReader: Sendable { "source": "TUnion SFI 18", "transactionType": String(format: "%02X", transactionType), "station": station, - ], + ] ) } @@ -297,7 +297,7 @@ public struct TUnionReader: Sendable { metadata: [ "source": "TUnion SFI 1E", "raw": data.hexString, - ], + ] ) } diff --git a/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift index 74defe4..77a7bc6 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/HongKong/OctopusReader.swift @@ -12,7 +12,7 @@ public struct OctopusReader: Sendable { public init( transport: any FeliCaTagTransporting, - balanceOffset: Int, + balanceOffset: Int ) { self.transport = transport self.balanceOffset = balanceOffset @@ -24,7 +24,7 @@ public struct OctopusReader: Sendable { NFCLog.debug("Octopus request service balance=\(OctopusConstants.balanceServiceCode.hexString)", source: "Octopus") let versions = try await transport.requestService( - nodeCodeList: [OctopusConstants.balanceServiceCode], + nodeCodeList: [OctopusConstants.balanceServiceCode] ) NFCLog.debug("Octopus balance service versions=\(versions.map(\.hexString).joined(separator: ","))", source: "Octopus") guard let version = versions.first, version != Data([0xFF, 0xFF]) else { @@ -33,7 +33,7 @@ public struct OctopusReader: Sendable { let blocks = try await transport.readWithoutEncryption( serviceCode: OctopusConstants.balanceServiceCode, - blockList: [FeliCaFrame.blockListElement(blockNumber: 0)], + blockList: [FeliCaFrame.blockListElement(blockNumber: 0)] ) guard let block = blocks.first, block.count >= 4 else { NFCLog.error("Octopus invalid balance block=\((blocks.first ?? Data()).hexString)", source: "Octopus") @@ -44,7 +44,7 @@ public struct OctopusReader: Sendable { let balanceCents = Self.balanceCents(rawValue: rawValue, offset: balanceOffset) NFCLog.debug( "Octopus balance block=\(block.hexString) raw=\(rawValue) offset=\(balanceOffset) cents=\(balanceCents)", - source: "Octopus", + source: "Octopus" ) NFCLog.info("Octopus balance read complete balanceCents=\(balanceCents)", source: "Octopus") @@ -52,7 +52,7 @@ public struct OctopusReader: Sendable { serialNumber: transport.identifier.hexString, balanceRaw: balanceCents, currencyCode: "HKD", - cardName: "Octopus", + cardName: "Octopus" ) } @@ -64,7 +64,7 @@ public struct OctopusReader: Sendable { NFCLog.debug("Octopus system code=\(transport.systemCode.hexString) expected=\(OctopusConstants.systemCode.hexString)", source: "Octopus") guard transport.systemCode == OctopusConstants.systemCode else { throw NFCError.unsupportedOperation( - "Expected FeliCa system code 0x8008 (Octopus), got \(transport.systemCode.hexString)", + "Expected FeliCa system code 0x8008 (Octopus), got \(transport.systemCode.hexString)" ) } } diff --git a/Sources/CoreExtendedNFC/Cards/Transit/Japan/JapanICReader.swift b/Sources/CoreExtendedNFC/Cards/Transit/Japan/JapanICReader.swift index be68a1c..164b55c 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/Japan/JapanICReader.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/Japan/JapanICReader.swift @@ -28,7 +28,7 @@ public struct JapanICReader: Sendable { serialNumber: transport.identifier.hexString, balanceRaw: balance, currencyCode: "JPY", - cardName: "Japan IC", + cardName: "Japan IC" ) } @@ -44,7 +44,7 @@ public struct JapanICReader: Sendable { balanceRaw: balance, currencyCode: "JPY", cardName: "Japan IC", - transactions: transactions, + transactions: transactions ) } @@ -54,7 +54,7 @@ public struct JapanICReader: Sendable { NFCLog.debug("Japan IC system code=\(transport.systemCode.hexString) expected=\(JapanICConstants.systemCode.hexString)", source: "JapanIC") guard transport.systemCode == JapanICConstants.systemCode else { throw NFCError.unsupportedOperation( - "Expected FeliCa system code 0x0003 (CJRC), got \(transport.systemCode.hexString)", + "Expected FeliCa system code 0x0003 (CJRC), got \(transport.systemCode.hexString)" ) } } @@ -63,7 +63,7 @@ public struct JapanICReader: Sendable { // Verify balance service exists NFCLog.debug("Japan IC request service balance=\(JapanICConstants.balanceServiceCode.hexString)", source: "JapanIC") let versions = try await transport.requestService( - nodeCodeList: [JapanICConstants.balanceServiceCode], + nodeCodeList: [JapanICConstants.balanceServiceCode] ) NFCLog.debug("Japan IC balance service versions=\(versions.map(\.hexString).joined(separator: ","))", source: "JapanIC") guard let version = versions.first, @@ -75,7 +75,7 @@ public struct JapanICReader: Sendable { // Read single balance block let blocks = try await transport.readWithoutEncryption( serviceCode: JapanICConstants.balanceServiceCode, - blockList: [FeliCaFrame.blockListElement(blockNumber: 0)], + blockList: [FeliCaFrame.blockListElement(blockNumber: 0)] ) guard let block = blocks.first, block.count >= JapanICConstants.balanceOffset + 2 @@ -88,7 +88,7 @@ public struct JapanICReader: Sendable { let balance = Int(balanceBytes.uint16LE) NFCLog.debug( "Japan IC balance block=\(block.hexString) offset=\(JapanICConstants.balanceOffset) bytes=\(balanceBytes.hexString) value=\(balance)", - source: "JapanIC", + source: "JapanIC" ) return balance } @@ -102,7 +102,7 @@ public struct JapanICReader: Sendable { do { let blocks = try await transport.readWithoutEncryption( serviceCode: JapanICConstants.historyServiceCode, - blockList: [FeliCaFrame.blockListElement(blockNumber: UInt16(i))], + blockList: [FeliCaFrame.blockListElement(blockNumber: UInt16(i))] ) guard let block = blocks.first, block.count == 16 else { NFCLog.debug("Japan IC history block #\(i) invalid=\((blocks.first ?? Data()).hexString)", source: "JapanIC") @@ -163,7 +163,7 @@ public struct JapanICReader: Sendable { balanceAfter: balanceAfter, date: date, entryStation: entryStation, - exitStation: exitStation, + exitStation: exitStation ) } diff --git a/Sources/CoreExtendedNFC/Cards/Transit/Korea/KSX6924Reader.swift b/Sources/CoreExtendedNFC/Cards/Transit/Korea/KSX6924Reader.swift index f748a5b..62d1a52 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/Korea/KSX6924Reader.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/Korea/KSX6924Reader.swift @@ -33,7 +33,7 @@ public struct KSX6924Reader: Sendable { currencyCode: "KRW", cardName: cardName, validFrom: purseInfo.issueDate, - validUntil: purseInfo.expiryDate, + validUntil: purseInfo.expiryDate ) } @@ -53,7 +53,7 @@ public struct KSX6924Reader: Sendable { cardName: cardName, validFrom: purseInfo.issueDate, validUntil: purseInfo.expiryDate, - transactions: transactions, + transactions: transactions ) } @@ -85,7 +85,7 @@ public struct KSX6924Reader: Sendable { ins: KSX6924Constants.INS_GET_BALANCE, p1: 0x00, p2: 0x00, - le: KSX6924Constants.BALANCE_RESP_LEN, + le: KSX6924Constants.BALANCE_RESP_LEN ) let response = try await transport.sendAPDUWithChaining(apdu) NFCLog.debug("KSX6924 GET BALANCE sw=\(String(format: "%02X%02X", response.sw1, response.sw2)) data=\(response.data.hexString)", source: "KSX6924") @@ -105,7 +105,7 @@ public struct KSX6924Reader: Sendable { ins: KSX6924Constants.INS_GET_RECORD, p1: UInt8(i), p2: 0x00, - le: 0x10, + le: 0x10 ) let response = try await transport.sendAPDUWithChaining(apdu) guard response.isSuccess, response.data.count >= 14 else { break } @@ -200,7 +200,7 @@ public struct KSX6924Reader: Sendable { type: txType, amount: amount, balanceAfter: balance, - date: date, + date: date ) } } diff --git a/Sources/CoreExtendedNFC/Cards/Transit/TransitBalance.swift b/Sources/CoreExtendedNFC/Cards/Transit/TransitBalance.swift index df5cab1..abdf518 100644 --- a/Sources/CoreExtendedNFC/Cards/Transit/TransitBalance.swift +++ b/Sources/CoreExtendedNFC/Cards/Transit/TransitBalance.swift @@ -27,7 +27,7 @@ public struct TransitBalance: Sendable, Equatable, Codable { validFrom: Date? = nil, validUntil: Date? = nil, transactions: [TransitTransaction] = [], - metadata: [String: String] = [:], + metadata: [String: String] = [:] ) { self.serialNumber = serialNumber self.balanceRaw = balanceRaw @@ -96,7 +96,7 @@ public struct TransitTransaction: Sendable, Equatable, Codable { exitStation: String? = nil, recordNumber: Int? = nil, rawData: Data? = nil, - metadata: [String: String] = [:], + metadata: [String: String] = [:] ) { self.type = type self.amount = amount diff --git a/Tests/CoreExtendedNFCTests/JapanICTests.swift b/Tests/CoreExtendedNFCTests/JapanICTests.swift index e1b7d78..8e877f2 100644 --- a/Tests/CoreExtendedNFCTests/JapanICTests.swift +++ b/Tests/CoreExtendedNFCTests/JapanICTests.swift @@ -22,7 +22,7 @@ struct JapanICTests { let transport = MockFeliCaServiceTransport( serviceVersions: [Data([0x8B, 0x00]): Data([0x00, 0x10])], serviceBlocks: [Data([0x8B, 0x00]): [balanceBlock]], - systemCode: Data([0x00, 0x03]), + systemCode: Data([0x00, 0x03]) ) let reader = JapanICReader(transport: transport) @@ -64,7 +64,7 @@ struct JapanICTests { Data([0x8B, 0x00]): [balanceBlock], Data([0x0F, 0x09]): [historyBlock], ], - systemCode: Data([0x00, 0x03]), + systemCode: Data([0x00, 0x03]) ) let reader = JapanICReader(transport: transport) @@ -178,7 +178,7 @@ struct JapanICTests { func `System code mismatch throws error`() async { let transport = MockFeliCaServiceTransport( serviceVersions: [:], - systemCode: Data([0x88, 0xB4]), // wrong system code + systemCode: Data([0x88, 0xB4]) // wrong system code ) let reader = JapanICReader(transport: transport) @@ -191,7 +191,7 @@ struct JapanICTests { func `Balance service unavailable throws error`() async { let transport = MockFeliCaServiceTransport( serviceVersions: [Data([0x8B, 0x00]): Data([0xFF, 0xFF])], // service not found - systemCode: Data([0x00, 0x03]), + systemCode: Data([0x00, 0x03]) ) let reader = JapanICReader(transport: transport) @@ -216,7 +216,7 @@ struct JapanICTests { let calendar = Calendar(identifier: .gregorian) let components = try calendar.dateComponents( in: #require(TimeZone(identifier: "Asia/Tokyo")), - from: #require(date), + from: #require(date) ) #expect(components.year == 2025) #expect(components.month == 1) @@ -283,7 +283,7 @@ struct JapanICTests { let transport = MockFeliCaServiceTransport( serviceVersions: [Data([0x8B, 0x00]): Data([0x00, 0x10])], serviceBlocks: [Data([0x8B, 0x00]): [balanceBlock]], - systemCode: Data([0x00, 0x03]), + systemCode: Data([0x00, 0x03]) ) let reader = JapanICReader(transport: transport) @@ -306,7 +306,7 @@ struct JapanICTests { JapanICConstants.historyServiceCode: historyBlocks.map(hexToData), ], systemCode: JapanICConstants.systemCode, - identifier: hexToData(identifier), + identifier: hexToData(identifier) ) } diff --git a/Tests/CoreExtendedNFCTests/KSX6924Tests.swift b/Tests/CoreExtendedNFCTests/KSX6924Tests.swift index 9ad61c7..39a60d1 100644 --- a/Tests/CoreExtendedNFCTests/KSX6924Tests.swift +++ b/Tests/CoreExtendedNFCTests/KSX6924Tests.swift @@ -118,7 +118,7 @@ struct KSX6924Tests { let calendar = Calendar(identifier: .gregorian) let components = try calendar.dateComponents( in: #require(TimeZone(identifier: "Asia/Seoul")), - from: #require(date), + from: #require(date) ) #expect(components.year == 2025) #expect(components.month == 12) diff --git a/Tests/CoreExtendedNFCTests/OctopusTests.swift b/Tests/CoreExtendedNFCTests/OctopusTests.swift index f18c7be..dff99d1 100644 --- a/Tests/CoreExtendedNFCTests/OctopusTests.swift +++ b/Tests/CoreExtendedNFCTests/OctopusTests.swift @@ -30,7 +30,7 @@ struct OctopusTests { Data([0x17, 0x01]): [hexToData("00000398000000000000000000000003")], ], systemCode: Data([0x80, 0x08]), - identifier: hexToData("010107015823C200"), + identifier: hexToData("010107015823C200") ) let result = try await OctopusReader(transport: transport).readBalance() @@ -51,12 +51,12 @@ struct OctopusTests { let transport = MockFeliCaServiceTransport( serviceVersions: [Data([0x17, 0x01]): Data([0x00, 0x10])], serviceBlocks: [Data([0x17, 0x01]): [block]], - systemCode: Data([0x80, 0x08]), + systemCode: Data([0x80, 0x08]) ) let result = try await OctopusReader( transport: transport, - balanceOffset: OctopusConstants.defaultBalanceRawOffset, + balanceOffset: OctopusConstants.defaultBalanceRawOffset ).readBalance() #expect(result.balanceRaw == 42690) @@ -69,7 +69,7 @@ struct OctopusTests { func `Octopus pre-2017 offset remains available`() { let cents = OctopusReader.balanceCents( rawValue: 4557, - offset: OctopusConstants.legacyBalanceRawOffset, + offset: OctopusConstants.legacyBalanceRawOffset ) #expect(cents == 42070) @@ -79,7 +79,7 @@ struct OctopusTests { func `Octopus system code mismatch throws error`() async { let transport = MockFeliCaServiceTransport( serviceVersions: [:], - systemCode: Data([0x00, 0x03]), + systemCode: Data([0x00, 0x03]) ) await #expect(throws: NFCError.self) { @@ -97,7 +97,7 @@ struct OctopusTests { return MockFeliCaServiceTransport( serviceVersions: [Data([0x17, 0x01]): Data([0x00, 0x10])], serviceBlocks: [Data([0x17, 0x01]): [block]], - systemCode: Data([0x80, 0x08]), + systemCode: Data([0x80, 0x08]) ) } diff --git a/Tests/CoreExtendedNFCTests/TUnionTests.swift b/Tests/CoreExtendedNFCTests/TUnionTests.swift index e2db91e..937fd75 100644 --- a/Tests/CoreExtendedNFCTests/TUnionTests.swift +++ b/Tests/CoreExtendedNFCTests/TUnionTests.swift @@ -98,7 +98,7 @@ struct TUnionTests { let calendar = Calendar(identifier: .gregorian) let components = try calendar.dateComponents( in: #require(TimeZone(identifier: "Asia/Shanghai")), - from: #require(date), + from: #require(date) ) #expect(components.year == 2025) #expect(components.month == 12) @@ -214,7 +214,7 @@ struct TUnionTests { #expect(first.type == .topup) #expect(first.amount == 0) #expect(first.entryStation == "200099990040") - #expect(first.rawData.hexString == "0002000000000000000220009999004020200115151321") + #expect(try #require(first.rawData).hexString == "0002000000000000000220009999004020200115151321") } @Test @@ -245,7 +245,7 @@ struct TUnionTests { let first = try #require(result.transactions.first) #expect(first.recordNumber == 1) #expect(first.entryStation == "413100629380") - #expect(first.rawData.hexString == "00B1000000000000000941310062938020240310211508") + #expect(try #require(first.rawData).hexString == "00B1000000000000000941310062938020240310211508") let second = try #require(result.transactions.dropFirst().first) #expect(second.amount == -160) @@ -294,7 +294,7 @@ struct TUnionTests { let first = try #require(result.transactions.first) #expect(first.amount == -600) #expect(first.entryStation == "413101925969") - #expect(first.rawData.hexString == "0057000000000002580941310192596920240614142250") + #expect(try #require(first.rawData).hexString == "0057000000000002580941310192596920240614142250") } // MARK: - Records @@ -324,7 +324,7 @@ struct TUnionTests { #expect(tx.recordNumber == 1) #expect(tx.entryStation == "010203040506") #expect(tx.metadata["source"] == "TUnion SFI 18") - #expect(transport.sentAPDUs[4].bytes == Data([0x00, 0xB2, 0x01, 0xC4, 0x17])) + #expect(transport.sentAPDUs.contains { $0.bytes == Data([0x00, 0xB2, 0x01, 0xC4, 0x17]) }) } @Test @@ -340,7 +340,7 @@ struct TUnionTests { let date = try #require(TUnionReader.parseBCDDateTime(Data([0x20, 0x26, 0x05, 0x09, 0x12, 0x34, 0x56]))) let components = try Calendar(identifier: .gregorian).dateComponents( in: #require(TimeZone(identifier: "Asia/Shanghai")), - from: date, + from: date ) #expect(components.year == 2026) #expect(components.month == 5) diff --git a/Tests/CoreExtendedNFCTests/TransitBalanceTests.swift b/Tests/CoreExtendedNFCTests/TransitBalanceTests.swift index 9a92d48..98773d2 100644 --- a/Tests/CoreExtendedNFCTests/TransitBalanceTests.swift +++ b/Tests/CoreExtendedNFCTests/TransitBalanceTests.swift @@ -8,7 +8,7 @@ struct TransitBalanceTests { serialNumber: "", balanceRaw: 245, currencyCode: "TWD", - cardName: "EasyCard", + cardName: "EasyCard" ) #expect(balance.formattedBalance == "NT$245") diff --git a/docs/NFC-InfoPlist-Reference.md b/docs/NFC-InfoPlist-Reference.md index 2be21b7..03f11ed 100644 --- a/docs/NFC-InfoPlist-Reference.md +++ b/docs/NFC-InfoPlist-Reference.md @@ -107,7 +107,7 @@ These are the merged values collected from public iOS NFC apps and SDKs on GitHu | `A0000002472001` | ICAO travel document auxiliary application | Commonly included by ID verification SDKs for passports and national ID cards. | | `A000000167455349474E` | eSign application | The ASCII tail decodes to `ESIGN`. Common in European eID / signing-card configs. | | `A000000291` | Calypso transit AID prefix | Often used as a prefix-style match for Calypso transit cards. | -| `A000000404` | CardBal transit / stored-value application | Included to mirror CardBal's ISO 7816 polling list. | +| `A000000404` | CardBal transit / stored-value application | Included to mirror CardBal's ISO 7816 polling list. | | `A00000000386980701` | UnionPay payment application | Observed in packaged UnionPay-family iOS apps; exact post-select APDUs are issuer-specific. | | `A0000004520001` | Korean transit / stored-value ecosystem application | Seen in public mobile NFC configs used for Korean transit cards. | | `D4100000030001` | Korean transit application | Included by passport / identity apps that also support common transit-card detection. | @@ -116,7 +116,7 @@ These are the merged values collected from public iOS NFC apps and SDKs on GitHu | `D4100000300001` | KSX6924 Snapper / MOIBA-compatible transit application | Present in CardBal and used by the KSX6924 reader's Snapper / MOIBA probing path. | | `D4106509900020` | KSX6924 K-Cash transit application | Present in CardBal and used by the KSX6924 reader's K-Cash probing path. | | `A000000632010105` | China T-Union transit application | Required for the implemented T-Union balance reader and for CoreNFC ISO 7816 APDU access. | -| `A000000341000101` | Singapore CEPAS / EZ-Link discovery value | Included for transit-card discovery and logging while detailed balance support is researched. | +| `A000000341000101` | Singapore CEPAS / EZ-Link discovery value | Included for transit-card discovery and logging while detailed balance support is researched. | | `5041592E535A54` | Legacy Shenzhen Tong application (`PAY.SZT`) | Included for discovery and logging; confirmed T-Union balance support uses `A000000632010105`. | | `F049442E43484E` | China document application (observed) | Seen in packaged Chinese iOS apps; likely document-related, inferred from the ASCII tail `ID.CHN`. | | `A000000812010208` | Tangem card application | Documented by `tangem-sdk-ios`. |