diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/settings/PlaygroundSettings.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/settings/PlaygroundSettings.kt index b6f8069fbd2..8f363e53ea4 100644 --- a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/settings/PlaygroundSettings.kt +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/settings/PlaygroundSettings.kt @@ -559,6 +559,10 @@ internal class PlaygroundSettings private constructor( PassiveCaptchaDefinition, AttestationOnIntentConfirmationDefinition, EnableTapToAddSettingsDefinition, + FeatureFlagSettingsDefinition( + FeatureFlags.nfcDirect, + PlaygroundConfigurationData.IntegrationType.paymentFlows().toList(), + ), CustomStripeApiDefinition, ) diff --git a/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/TapToAddCollectionHandler.kt b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/TapToAddCollectionHandler.kt index 08136388fda..95ab7641877 100644 --- a/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/TapToAddCollectionHandler.kt +++ b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/TapToAddCollectionHandler.kt @@ -1,6 +1,9 @@ package com.stripe.android.common.taptoadd +import android.os.Build import com.stripe.android.common.exception.stripeErrorMessage +import com.stripe.android.common.taptoadd.nfcdirect.NfcDirectCollectionHandler +import com.stripe.android.common.taptoadd.nfcdirect.NfcDirectConnectionManager import com.stripe.android.core.strings.ResolvableString import com.stripe.android.core.strings.resolvableString import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadata @@ -44,6 +47,13 @@ internal interface TapToAddCollectionHandler { errorReporter: ErrorReporter, createCardPresentSetupIntentCallbackRetriever: CreateCardPresentSetupIntentCallbackRetriever, ): TapToAddCollectionHandler { + // Check if using NFC Direct + if (connectionManager is NfcDirectConnectionManager && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT + ) { + return NfcDirectCollectionHandler(connectionManager) + } + return if (isStripeTerminalSdkAvailable()) { DefaultTapToAddCollectionHandler( terminalWrapper = terminalWrapper, diff --git a/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/TapToAddModule.kt b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/TapToAddModule.kt index 9639bf51b2c..f7c3f458b93 100644 --- a/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/TapToAddModule.kt +++ b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/TapToAddModule.kt @@ -1,6 +1,10 @@ package com.stripe.android.common.taptoadd import android.content.Context +import android.os.Build +import com.stripe.android.common.taptoadd.nfcdirect.DefaultIsNfcDirectAvailable +import com.stripe.android.common.taptoadd.nfcdirect.IsNfcDirectAvailable +import com.stripe.android.common.taptoadd.nfcdirect.NfcDirectConnectionManager import com.stripe.android.core.injection.IOContext import com.stripe.android.paymentelement.CreateCardPresentSetupIntentCallback import com.stripe.android.paymentelement.TapToAddPreview @@ -31,6 +35,11 @@ internal interface TapToAddModule { retriever: DefaultCreateCardPresentSetupIntentCallbackRetriever ): CreateCardPresentSetupIntentCallbackRetriever + @Binds + fun bindsIsNfcDirectAvailable( + isNfcDirectAvailable: DefaultIsNfcDirectAvailable + ): IsNfcDirectAvailable + companion object { @Provides fun providesCreateCardPresentSetupIntentCallback( @@ -42,12 +51,22 @@ internal interface TapToAddModule { @Provides fun providesTapToAddConnectionManager( + isNfcDirectAvailable: IsNfcDirectAvailable, isStripeTerminalSdkAvailable: IsStripeTerminalSdkAvailable, terminalWrapper: TerminalWrapper, errorReporter: ErrorReporter, applicationContext: Context, @IOContext workContext: CoroutineContext ): TapToAddConnectionManager { + // Prefer NFC Direct (lightweight) over Terminal SDK when available + if (isNfcDirectAvailable() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + return NfcDirectConnectionManager( + context = applicationContext, + workContext = workContext, + ) + } + + // Fall back to Terminal SDK return TapToAddConnectionManager.create( applicationContext = applicationContext, isStripeTerminalSdkAvailable = isStripeTerminalSdkAvailable, diff --git a/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/CardDataExtractor.kt b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/CardDataExtractor.kt new file mode 100644 index 00000000000..7496cbad083 --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/CardDataExtractor.kt @@ -0,0 +1,385 @@ +package com.stripe.android.common.taptoadd.nfcdirect + +import com.stripe.android.common.taptoadd.nfcdirect.TlvParser.toHexString + +/** + * Extracts card data from EMV TLV responses. + * + * Handles extraction of: + * - PAN (Primary Account Number) + * - Expiration date + * - Cardholder name + * - Application Identifier (AID) + * - Card scheme/brand detection + */ +internal class CardDataExtractor { + + /** + * Extracted card data result. + */ + data class CardData( + val pan: String, + val expiryMonth: Int, + val expiryYear: Int, + val cardholderName: String?, + val aid: ByteArray, + val scheme: CardScheme + ) { + val last4: String get() = pan.takeLast(4) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CardData) return false + return pan == other.pan && expiryMonth == other.expiryMonth && + expiryYear == other.expiryYear && aid.contentEquals(other.aid) + } + + override fun hashCode(): Int { + var result = pan.hashCode() + result = 31 * result + expiryMonth + result = 31 * result + expiryYear + result = 31 * result + aid.contentHashCode() + return result + } + } + + /** + * Supported card schemes. + */ + enum class CardScheme(val code: String) { + VISA("visa"), + MASTERCARD("mastercard"), + AMEX("amex"), + DISCOVER("discover"), + JCB("jcb"), + UNIONPAY("unionpay"), + UNKNOWN("unknown") + } + + // EMV Tags + companion object { + const val TAG_AID = "4F" // Application Identifier (DF Name) + const val TAG_APP_LABEL = "50" // Application Label + const val TAG_TRACK2 = "57" // Track 2 Equivalent Data + const val TAG_PAN = "5A" // Application PAN + const val TAG_CARDHOLDER_NAME = "5F20" // Cardholder Name + const val TAG_EXPIRY = "5F24" // Application Expiration Date + const val TAG_PAN_SEQUENCE = "5F34" // PAN Sequence Number + const val TAG_FCI_TEMPLATE = "6F" // FCI Template + const val TAG_DF_NAME = "84" // DF Name (AID in FCI) + const val TAG_FCI_PROP = "A5" // FCI Proprietary Template + const val TAG_AFL = "94" // Application File Locator + const val TAG_AIP = "82" // Application Interchange Profile + const val TAG_PDOL = "9F38" // PDOL + const val TAG_RESPONSE_FORMAT1 = "80" // Response Message Template Format 1 + const val TAG_RESPONSE_FORMAT2 = "77" // Response Message Template Format 2 + const val TAG_DIRECTORY_ENTRY = "61" // Application Template + const val TAG_ADF_NAME = "4F" // ADF Name (in directory entry) + } + + /** + * Extract AID from PPSE response. + * + * PPSE response contains directory entries with available AIDs. + * We prefer known payment AIDs in order: Visa, MC, Amex, Discover, JCB. + */ + fun extractAid(ppseResponse: ByteArray): ByteArray? { + val tlv = TlvParser.parse(EmvApduCommands.getResponseData(ppseResponse)) + + // Try to find AID directly + tlv[TAG_AID]?.let { return it } + tlv[TAG_DF_NAME]?.let { return it } + tlv[TAG_ADF_NAME]?.let { return it } + + // Look for known AIDs in the response + return findPreferredAid(tlv) + } + + /** + * Extract PDOL (Processing Options Data Object List) from SELECT AID response. + * + * PDOL tells us what data the card needs for GPO command. + * For simple card reading, we can often send empty PDOL values. + */ + fun extractPdol(selectResponse: ByteArray): ByteArray? { + val tlv = TlvParser.parse(EmvApduCommands.getResponseData(selectResponse)) + return tlv[TAG_PDOL] + } + + /** + * Extract AFL from GPO response. + * + * AFL tells us which records to read to get card data. + */ + fun extractAfl(gpoResponse: ByteArray): ByteArray? { + val responseData = EmvApduCommands.getResponseData(gpoResponse) + val tlv = TlvParser.parse(responseData) + + // Try format 2 (tag 77) first - contains AFL directly + tlv[TAG_AFL]?.let { return it } + + // Format 1 (tag 80): AIP (2 bytes) + AFL + // Note: For some qVSDC cards, this might be AIP + Track 2 instead of AFL + tlv[TAG_RESPONSE_FORMAT1]?.let { format1Data -> + if (format1Data.size > 2) { + val possibleAfl = format1Data.copyOfRange(2, format1Data.size) + // AFL entries are always 4 bytes each + if (possibleAfl.size % 4 == 0 && possibleAfl.isNotEmpty()) { + // Check if first byte looks like a valid SFI (bits 3-7 set, shifted left) + val firstByte = possibleAfl[0].toInt() and 0xFF + if (firstByte in 0x08..0xF8 && (firstByte and 0x07) == 0) { + return possibleAfl + } + } + } + } + + return null + } + + /** + * Extract Track 2 data from GPO Format 1 response if present. + * Some Visa qVSDC cards return Track 2 equivalent in GPO response. + */ + fun extractTrack2FromGpo(gpoResponse: ByteArray): ByteArray? { + val responseData = EmvApduCommands.getResponseData(gpoResponse) + val tlv = TlvParser.parse(responseData) + + // Format 1 (tag 80) might contain AIP (2 bytes) + Track 2 equivalent + tlv[TAG_RESPONSE_FORMAT1]?.let { format1Data -> + if (format1Data.size > 2) { + val dataAfterAip = format1Data.copyOfRange(2, format1Data.size) + // Track 2 starts with PAN which starts with 3, 4, 5, or 6 + if (dataAfterAip.isNotEmpty()) { + val firstNibble = (dataAfterAip[0].toInt() and 0xF0) shr 4 + if (firstNibble in 3..6) { + // Looks like Track 2 data (PAN starts with valid digit) + return dataAfterAip + } + } + } + } + + return null + } + + /** + * Extract card data from all collected record data. + * + * @param records Concatenated record data from READ RECORD commands + * @param aid The selected AID for scheme detection + */ + fun extract(records: ByteArray, aid: ByteArray): CardData { + val tlv = TlvParser.parse(records) + + // Extract PAN - try direct tag first, then Track 2 + val pan = extractPan(tlv) + ?: throw CardDataExtractionException("PAN not found in card data") + + // Extract expiry date + val (month, year) = extractExpiry(tlv) + ?: throw CardDataExtractionException("Expiry date not found in card data") + + // Extract cardholder name (optional) + val cardholderName = extractCardholderName(tlv) + + // Detect scheme from AID + val scheme = detectScheme(aid) + + return CardData( + pan = pan, + expiryMonth = month, + expiryYear = year, + cardholderName = cardholderName, + aid = aid, + scheme = scheme + ) + } + + /** + * Extract card data from pre-parsed TLV map. + * This is useful when TLV data has been accumulated from multiple responses + * (GPO + READ RECORDs). + * + * @param tlv Pre-parsed TLV data map + * @param aid The selected AID for scheme detection + */ + fun extractFromTlvMap(tlv: Map, aid: ByteArray): CardData { + // Extract PAN - try direct tag first, then Track 2 + val pan = extractPan(tlv) + ?: throw CardDataExtractionException( + "PAN not found in card data. Available tags: ${tlv.keys.joinToString()}" + ) + + // Extract expiry date + val (month, year) = extractExpiry(tlv) + ?: throw CardDataExtractionException( + "Expiry date not found in card data. Available tags: ${tlv.keys.joinToString()}" + ) + + // Extract cardholder name (optional) + val cardholderName = extractCardholderName(tlv) + + // Detect scheme from AID + val scheme = detectScheme(aid) + + return CardData( + pan = pan, + expiryMonth = month, + expiryYear = year, + cardholderName = cardholderName, + aid = aid, + scheme = scheme + ) + } + + /** + * Extract PAN from TLV data. + */ + private fun extractPan(tlv: Map): String? { + // Try tag 5A (Application PAN) + tlv[TAG_PAN]?.let { panBytes -> + return decodeBcdPan(panBytes) + } + + // Fallback to Track 2 data + tlv[TAG_TRACK2]?.let { track2 -> + return extractPanFromTrack2(track2) + } + + return null + } + + /** + * Extract expiry date from TLV data. + * + * @return Pair of (month, year) where year is 4-digit + */ + private fun extractExpiry(tlv: Map): Pair? { + // Try tag 5F24 (YYMMDD format in BCD) + tlv[TAG_EXPIRY]?.let { expiryBytes -> + if (expiryBytes.size >= 2) { + val yy = decodeBcd(expiryBytes[0]) + val mm = decodeBcd(expiryBytes[1]) + // Convert 2-digit year to 4-digit (assume 20xx) + val year = 2000 + yy + return mm to year + } + } + + // Fallback to Track 2 data + tlv[TAG_TRACK2]?.let { track2 -> + return extractExpiryFromTrack2(track2) + } + + return null + } + + /** + * Extract cardholder name from TLV data. + */ + private fun extractCardholderName(tlv: Map): String? { + return tlv[TAG_CARDHOLDER_NAME]?.let { nameBytes -> + // Name is ASCII encoded, trim trailing spaces and slash + String(nameBytes, Charsets.US_ASCII) + .trim() + .trimEnd('/') + .takeIf { it.isNotEmpty() } + } + } + + /** + * Decode BCD-encoded PAN. + * PAN is encoded as packed BCD, with 'F' padding at end if odd length. + */ + private fun decodeBcdPan(data: ByteArray): String { + val sb = StringBuilder() + for (byte in data) { + val high = (byte.toInt() shr 4) and 0x0F + val low = byte.toInt() and 0x0F + if (high < 10) sb.append(high) + if (low < 10) sb.append(low) // Skip 'F' padding + } + return sb.toString() + } + + /** + * Decode single BCD byte to integer. + */ + private fun decodeBcd(byte: Byte): Int { + val high = (byte.toInt() shr 4) and 0x0F + val low = byte.toInt() and 0x0F + return high * 10 + low + } + + /** + * Extract PAN from Track 2 equivalent data. + * Format: PAN + 'D' separator + Expiry + Service Code + Discretionary Data + */ + private fun extractPanFromTrack2(track2: ByteArray): String? { + val hexString = track2.toHexString() + val separatorIndex = hexString.indexOf('D') + if (separatorIndex == -1) return null + return hexString.substring(0, separatorIndex) + } + + /** + * Extract expiry from Track 2 equivalent data. + */ + private fun extractExpiryFromTrack2(track2: ByteArray): Pair? { + val hexString = track2.toHexString() + val separatorIndex = hexString.indexOf('D') + if (separatorIndex == -1 || separatorIndex + 5 > hexString.length) return null + + // Expiry is YYMM format after separator + val expiryStr = hexString.substring(separatorIndex + 1, separatorIndex + 5) + return try { + val yy = expiryStr.substring(0, 2).toInt() + val mm = expiryStr.substring(2, 4).toInt() + val year = 2000 + yy + mm to year + } catch (e: NumberFormatException) { + null + } + } + + /** + * Detect card scheme from AID. + */ + private fun detectScheme(aid: ByteArray): CardScheme { + val aidHex = aid.toHexString().uppercase() + + return when { + aidHex.startsWith("A000000003") -> CardScheme.VISA + aidHex.startsWith("A000000004") -> CardScheme.MASTERCARD + aidHex.startsWith("A000000025") -> CardScheme.AMEX + aidHex.startsWith("A000000152") -> CardScheme.DISCOVER + aidHex.startsWith("A000000065") -> CardScheme.JCB + aidHex.startsWith("A000000333") -> CardScheme.UNIONPAY + else -> CardScheme.UNKNOWN + } + } + + /** + * Find preferred AID from PPSE response. + * Looks for common payment AIDs in priority order. + */ + private fun findPreferredAid(tlv: Map): ByteArray? { + // Check each tag value for known AID patterns + for ((_, value) in tlv) { + val hex = value.toHexString().uppercase() + when { + hex.startsWith("A000000003") -> return value // Visa + hex.startsWith("A000000004") -> return value // Mastercard + hex.startsWith("A000000025") -> return value // Amex + hex.startsWith("A000000152") -> return value // Discover + } + } + return null + } +} + +/** + * Exception thrown when card data extraction fails. + */ +internal class CardDataExtractionException(message: String) : Exception(message) diff --git a/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/DigitalWalletDetector.kt b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/DigitalWalletDetector.kt new file mode 100644 index 00000000000..cfc6e19e876 --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/DigitalWalletDetector.kt @@ -0,0 +1,169 @@ +package com.stripe.android.common.taptoadd.nfcdirect + +import com.stripe.android.common.taptoadd.nfcdirect.TlvParser.toHexString + +/** + * Detects digital wallet taps (Apple Pay, Google Pay, Samsung Pay, etc.). + * + * Digital wallets use tokenized PANs (DPANs) instead of the actual card number. + * For "Tap to Add" scenarios where we need the actual card number for CNP + * transactions, we must reject wallet taps and prompt the user to tap + * their physical card instead. + * + * Detection is based on EMV tags that indicate tokenized transactions: + * - Mastercard: CVM Results (9F34) with specific patterns + * - Visa: Card Transaction Qualifiers (C1) presence + * - Amex: Enhanced Contactless Reader Capabilities (9F71) + * - Discover: Issuer Application Data (9F10) with DPAN indicator + */ +internal class DigitalWalletDetector { + + companion object { + // EMV tags used for wallet detection + const val TAG_CVM_RESULTS = "9F34" // Mastercard + const val TAG_CTQ = "C1" // Visa Card Transaction Qualifiers + const val TAG_ENHANCED_CAPABILITIES = "9F71" // Amex + const val TAG_IAD = "9F10" // Issuer Application Data (Discover) + const val TAG_AID = "4F" // Application Identifier + const val TAG_DF_NAME = "84" // DF Name + + // Mastercard DPAN indicator in CVM Results + // When byte 1 bit 4 is set, indicates mobile payment + private const val MC_MOBILE_CVM_MASK = 0x08 + + // Discover DPAN indicator position in IAD + private const val DISCOVER_DPAN_BYTE_INDEX = 0 + private const val DISCOVER_DPAN_INDICATOR = 0x01 + } + + /** + * Check if the card tap is from a digital wallet. + * + * @param allTlvData Combined TLV data from GPO and READ RECORD responses + * @param aid The selected application AID + * @return true if this appears to be a digital wallet tap + */ + fun isDigitalWallet(allTlvData: Map, aid: ByteArray): Boolean { + val scheme = detectScheme(aid) + + return when (scheme) { + CardScheme.MASTERCARD -> checkMastercard(allTlvData) + CardScheme.VISA -> checkVisa(allTlvData) + CardScheme.AMEX -> checkAmex(allTlvData) + CardScheme.DISCOVER -> checkDiscover(allTlvData) + else -> false // Conservative: allow unknown schemes + } + } + + /** + * Mastercard digital wallet detection. + * + * Checks CVM Results (9F34) for mobile payment indicators. + * Mobile wallets typically use CDCVM (Consumer Device CVM) which sets + * specific bits in the CVM Results. + */ + private fun checkMastercard(tlv: Map): Boolean { + val cvmResults = tlv[TAG_CVM_RESULTS] ?: return false + + if (cvmResults.isEmpty()) return false + + // Check for mobile/CDCVM indicator + val cvmType = cvmResults[0].toInt() and 0xFF + if ((cvmType and MC_MOBILE_CVM_MASK) != 0) { + return true + } + + // Additional check: CVM performed on consumer device + // Values 0x1F and 0x1E typically indicate mobile device verification + if (cvmType == 0x1F || cvmType == 0x1E) { + return true + } + + return false + } + + /** + * Visa digital wallet detection. + * + * Visa digital wallets include the Card Transaction Qualifiers (CTQ) tag C1 + * in their responses. This tag is typically only present for tokenized + * transactions. + */ + private fun checkVisa(tlv: Map): Boolean { + // Presence of CTQ tag often indicates digital wallet + val ctq = tlv[TAG_CTQ] + if (ctq != null && ctq.isNotEmpty()) { + // Bit 8 of byte 1 indicates online cryptogram required + // Bit 7 indicates CVM required + // Mobile wallets typically have both + val byte1 = ctq[0].toInt() and 0xFF + if ((byte1 and 0xC0) == 0xC0) { + return true + } + } + + return false + } + + /** + * American Express digital wallet detection. + * + * Amex digital wallets include Enhanced Contactless Reader Capabilities (9F71) + * with specific mobile payment indicators. + */ + private fun checkAmex(tlv: Map): Boolean { + val enhancedCap = tlv[TAG_ENHANCED_CAPABILITIES] ?: return false + + if (enhancedCap.isEmpty()) return false + + // Check for mobile payment indicator in first byte + val flags = enhancedCap[0].toInt() and 0xFF + + // Bit 1 typically indicates mobile payment capability + return (flags and 0x01) != 0 + } + + /** + * Discover digital wallet detection. + * + * Discover uses Issuer Application Data (IAD) tag 9F10 to indicate + * whether the PAN is a Device PAN (DPAN) or the actual FPAN. + */ + private fun checkDiscover(tlv: Map): Boolean { + val iad = tlv[TAG_IAD] ?: return false + + if (iad.size <= DISCOVER_DPAN_BYTE_INDEX) return false + + // Check DPAN indicator byte + val indicator = iad[DISCOVER_DPAN_BYTE_INDEX].toInt() and 0xFF + + return (indicator and DISCOVER_DPAN_INDICATOR) != 0 + } + + private fun detectScheme(aid: ByteArray): CardScheme { + val aidHex = aid.toHexString().uppercase() + + return when { + aidHex.startsWith("A000000003") -> CardScheme.VISA + aidHex.startsWith("A000000004") -> CardScheme.MASTERCARD + aidHex.startsWith("A000000025") -> CardScheme.AMEX + aidHex.startsWith("A000000152") -> CardScheme.DISCOVER + else -> CardScheme.UNKNOWN + } + } + + private enum class CardScheme { + VISA, + MASTERCARD, + AMEX, + DISCOVER, + UNKNOWN + } +} + +/** + * Exception thrown when a digital wallet tap is detected. + */ +internal class DigitalWalletNotSupportedException( + message: String = "Digital wallets are not supported. Please tap your physical card." +) : Exception(message) diff --git a/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/EmvApduCommands.kt b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/EmvApduCommands.kt new file mode 100644 index 00000000000..6d5685412fd --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/EmvApduCommands.kt @@ -0,0 +1,186 @@ +package com.stripe.android.common.taptoadd.nfcdirect + +/** + * EMV contactless APDU command builders. + * + * Implements the minimal command set required for Card Not Present (CNP) card data reading: + * - SELECT PPSE (Proximity Payment System Environment) + * - SELECT AID (Application Identifier) + * - GET PROCESSING OPTIONS + * - READ RECORD + * + * Note: GENERATE AC is intentionally NOT included as this is for CNP use only. + */ +internal object EmvApduCommands { + + // APDU instruction codes + private const val CLA_ISO = 0x00.toByte() + private const val CLA_EMV = 0x80.toByte() + private const val INS_SELECT = 0xA4.toByte() + private const val INS_READ_RECORD = 0xB2.toByte() + private const val INS_GPO = 0xA8.toByte() + + // SELECT command parameters + private const val P1_SELECT_BY_NAME = 0x04.toByte() + private const val P2_SELECT_FIRST = 0x00.toByte() + + /** + * PPSE name: "2PAY.SYS.DDF01" + * Used for contactless payment applications discovery. + */ + private val PPSE_NAME = "2PAY.SYS.DDF01".toByteArray(Charsets.US_ASCII) + + /** + * Common payment application AIDs. + */ + object Aids { + val VISA = "A0000000031010".hexToByteArray() + val MASTERCARD = "A0000000041010".hexToByteArray() + val AMEX = "A00000002501".hexToByteArray() + val DISCOVER = "A0000001523010".hexToByteArray() + val JCB = "A0000000651010".hexToByteArray() + val UNIONPAY = "A000000333010101".hexToByteArray() + } + + /** + * SELECT PPSE command. + * First command in EMV contactless flow to discover available applications. + * + * Returns FCI (File Control Information) template with available AIDs. + */ + val SELECT_PPSE: ByteArray = buildSelectCommand(PPSE_NAME) + + /** + * Build SELECT command for a specific AID. + * + * @param aid Application Identifier to select + * @return Complete APDU command bytes + */ + fun selectAid(aid: ByteArray): ByteArray = buildSelectCommand(aid) + + /** + * Build GET PROCESSING OPTIONS command. + * + * Initiates the EMV transaction and returns: + * - AIP (Application Interchange Profile) + * - AFL (Application File Locator) - tells us which records to read + * + * @param pdol PDOL (Processing Options Data Object List) data, or null if not required + * @return Complete APDU command bytes + */ + fun getProcessingOptions(pdol: ByteArray? = null): ByteArray { + val data = if (pdol != null && pdol.isNotEmpty()) { + // Construct PDOL data: tag 83 + length + PDOL values + byteArrayOf(0x83.toByte(), pdol.size.toByte()) + pdol + } else { + // Empty PDOL: tag 83 + length 00 + byteArrayOf(0x83.toByte(), 0x00) + } + + return byteArrayOf( + CLA_EMV, // CLA + INS_GPO, // INS: GET PROCESSING OPTIONS + 0x00, // P1 + 0x00, // P2 + data.size.toByte() // Lc + ) + data + byteArrayOf(0x00) // Le + } + + /** + * Build READ RECORD command. + * + * Reads a single record from the card based on AFL information. + * + * @param sfi Short File Identifier (1-30) + * @param recordNumber Record number within the file (1-255) + * @return Complete APDU command bytes + */ + fun readRecord(sfi: Int, recordNumber: Int): ByteArray { + require(sfi in 1..30) { "SFI must be between 1 and 30" } + require(recordNumber in 1..255) { "Record number must be between 1 and 255" } + + // P2: bits 3-7 = SFI, bits 1-2 = 100 (record number in P1) + val p2 = ((sfi shl 3) or 0x04).toByte() + + return byteArrayOf( + CLA_ISO, // CLA + INS_READ_RECORD, // INS: READ RECORD + recordNumber.toByte(), // P1: record number + p2, // P2: SFI reference + 0x00 // Le: expect full record + ) + } + + /** + * Parse AFL (Application File Locator) from GPO response. + * + * AFL format: sequence of 4-byte entries + * - Byte 1: SFI (shifted left by 3) + * - Byte 2: First record number + * - Byte 3: Last record number + * - Byte 4: Number of records involved in offline data authentication + * + * @return List of (sfi, firstRecord, lastRecord) tuples + */ + fun parseAfl(afl: ByteArray): List> { + val entries = mutableListOf>() + + if (afl.size % 4 != 0) return entries + + for (i in afl.indices step 4) { + val sfi = (afl[i].toInt() and 0xFF) shr 3 + val firstRecord = afl[i + 1].toInt() and 0xFF + val lastRecord = afl[i + 2].toInt() and 0xFF + // Byte 4 is ODA count, not needed for card data reading + + entries.add(Triple(sfi, firstRecord, lastRecord)) + } + + return entries + } + + /** + * Check if APDU response indicates success. + * SW1=90, SW2=00 indicates successful execution. + */ + fun isSuccess(response: ByteArray): Boolean { + if (response.size < 2) return false + val sw1 = response[response.size - 2].toInt() and 0xFF + val sw2 = response[response.size - 1].toInt() and 0xFF + return sw1 == 0x90 && sw2 == 0x00 + } + + /** + * Extract data from APDU response (removes status bytes). + */ + fun getResponseData(response: ByteArray): ByteArray { + if (response.size <= 2) return ByteArray(0) + return response.copyOfRange(0, response.size - 2) + } + + /** + * Get status word from APDU response. + */ + fun getStatusWord(response: ByteArray): Int { + if (response.size < 2) return 0 + val sw1 = response[response.size - 2].toInt() and 0xFF + val sw2 = response[response.size - 1].toInt() and 0xFF + return (sw1 shl 8) or sw2 + } + + private fun buildSelectCommand(name: ByteArray): ByteArray { + return byteArrayOf( + CLA_ISO, // CLA + INS_SELECT, // INS: SELECT + P1_SELECT_BY_NAME, // P1: Select by name + P2_SELECT_FIRST, // P2: First or only occurrence + name.size.toByte() // Lc: length of data + ) + name + byteArrayOf(0x00) // Data + Le + } + + private fun String.hexToByteArray(): ByteArray { + return ByteArray(length / 2) { i -> + substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/IsNfcDirectAvailable.kt b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/IsNfcDirectAvailable.kt new file mode 100644 index 00000000000..8ddb32afaf3 --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/IsNfcDirectAvailable.kt @@ -0,0 +1,41 @@ +package com.stripe.android.common.taptoadd.nfcdirect + +import android.content.Context +import android.nfc.NfcAdapter +import android.os.Build +import com.stripe.android.core.utils.FeatureFlags +import javax.inject.Inject + +/** + * Checks if NFC Direct card reading is available on this device. + * + * Requirements: + * - Feature flag enabled + * - Android 4.4 (KitKat) or higher for IsoDep support + * - NFC hardware present + * - NFC enabled in device settings + */ +internal fun interface IsNfcDirectAvailable { + operator fun invoke(): Boolean +} + +internal class DefaultIsNfcDirectAvailable @Inject constructor( + private val context: Context, +) : IsNfcDirectAvailable { + + override fun invoke(): Boolean { + // Check feature flag + if (!FeatureFlags.nfcDirect.isEnabled) { + return false + } + + // Require KitKat for proper IsoDep support + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + return false + } + + // Check NFC hardware and enabled state + val nfcAdapter = NfcAdapter.getDefaultAdapter(context) + return nfcAdapter?.isEnabled == true + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/NfcDirectActivityHolder.kt b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/NfcDirectActivityHolder.kt new file mode 100644 index 00000000000..87380a7b57b --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/NfcDirectActivityHolder.kt @@ -0,0 +1,41 @@ +package com.stripe.android.common.taptoadd.nfcdirect + +import android.app.Activity +import androidx.annotation.RestrictTo +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import java.lang.ref.WeakReference + +/** + * Holds a reference to the current Activity for NFC Direct operations. + * + * NFC Reader Mode requires an Activity reference. This holder allows the + * NfcDirectConnectionManager to be created via Dagger (which only has Context) + * and get the Activity reference when needed. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +object NfcDirectActivityHolder { + private var activityRef: WeakReference? = null + + /** + * Set the current activity. Call this from your Activity's onCreate. + */ + fun set(activity: Activity, lifecycleOwner: LifecycleOwner) { + activityRef = WeakReference(activity) + + lifecycleOwner.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + if (activityRef?.get() === activity) { + activityRef = null + } + } + } + ) + } + + /** + * Get the current activity, or null if not available. + */ + fun get(): Activity? = activityRef?.get() +} diff --git a/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/NfcDirectCollectionHandler.kt b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/NfcDirectCollectionHandler.kt new file mode 100644 index 00000000000..832c251b641 --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/NfcDirectCollectionHandler.kt @@ -0,0 +1,445 @@ +package com.stripe.android.common.taptoadd.nfcdirect + +import android.nfc.tech.IsoDep +import android.os.Build +import androidx.annotation.RequiresApi +import com.stripe.android.common.taptoadd.TapToAddCollectionHandler +import com.stripe.android.common.taptoadd.TapToAddCollectionHandler.CollectionState +import com.stripe.android.core.strings.resolvableString +import com.stripe.android.lpmfoundations.paymentmethod.PaymentMethodMetadata +import com.stripe.android.model.CardBrand +import com.stripe.android.model.PaymentMethod +import java.io.IOException + +/** + * Collection handler that reads card data directly via NFC using EMV commands. + * + * Implements the minimal EMV contactless flow for Card Not Present (CNP): + * 1. SELECT PPSE - Discover available payment applications + * 2. SELECT AID - Select the payment application + * 3. GET PROCESSING OPTIONS - Initialize transaction, get AFL + * 4. READ RECORD - Read card data (PAN, expiry, name) + * + * Note: Does NOT perform GENERATE AC (cryptogram generation) as this is for CNP only. + */ +@RequiresApi(Build.VERSION_CODES.KITKAT) +internal class NfcDirectCollectionHandler( + private val connectionManager: NfcDirectConnectionManager, + private val cardDataExtractor: CardDataExtractor = CardDataExtractor(), + private val digitalWalletDetector: DigitalWalletDetector = DigitalWalletDetector(), +) : TapToAddCollectionHandler { + + override suspend fun collect(metadata: PaymentMethodMetadata): CollectionState { + return try { + // Wait for card connection + if (!connectionManager.isConnected) { + connectionManager.connect() + connectionManager.awaitConnection().onFailure { throw it } + } + + val isoDep = connectionManager.getCurrentIsoDep() + ?: connectionManager.awaitTag() + + // Perform EMV transaction + val cardData = performEmvTransaction(isoDep) + + // Create PaymentMethod from card data + val paymentMethod = createPaymentMethod(cardData) + + CollectionState.Collected(paymentMethod) + } catch (e: DigitalWalletNotSupportedException) { + CollectionState.FailedCollection( + error = e, + displayMessage = "Digital wallets are not supported. Please tap your physical card.".resolvableString + ) + } catch (e: CardDataExtractionException) { + CollectionState.FailedCollection( + error = e, + displayMessage = "Could not read card data. Please try again.".resolvableString + ) + } catch (e: EmvTransactionException) { + CollectionState.FailedCollection( + error = e, + displayMessage = e.userMessage.resolvableString + ) + } catch (e: IOException) { + CollectionState.FailedCollection( + error = e, + displayMessage = "Card communication error. Please hold card steady and try again.".resolvableString + ) + } catch (e: Exception) { + CollectionState.FailedCollection( + error = e, + displayMessage = "An unexpected error occurred. Please try again.".resolvableString + ) + } finally { + connectionManager.disconnect() + } + } + + /** + * Perform the EMV contactless transaction to read card data. + */ + private fun performEmvTransaction(isoDep: IsoDep): CardDataExtractor.CardData { + // Collect all TLV data for digital wallet detection + val allTlvData = mutableMapOf() + + // Step 1: SELECT PPSE + val ppseResponse = transceive(isoDep, EmvApduCommands.SELECT_PPSE, "SELECT PPSE") + val ppseData = EmvApduCommands.getResponseData(ppseResponse) + allTlvData.putAll(TlvParser.parse(ppseData)) + + // Extract AID from PPSE response + val aid = cardDataExtractor.extractAid(ppseResponse) + ?: throw EmvTransactionException( + "No supported payment application found on card", + "This card is not supported. Please try a different card." + ) + + // Step 2: SELECT AID + val selectAidResponse = transceive(isoDep, EmvApduCommands.selectAid(aid), "SELECT AID") + val selectAidData = EmvApduCommands.getResponseData(selectAidResponse) + allTlvData.putAll(TlvParser.parse(selectAidData)) + + // Extract PDOL for GPO command (may be null if not required by card) + val pdol = cardDataExtractor.extractPdol(selectAidResponse) + val pdolData = buildPdolData(pdol) + + // Step 3: GET PROCESSING OPTIONS + val gpoResponse = try { + transceive( + isoDep, + EmvApduCommands.getProcessingOptions(pdolData), + "GET PROCESSING OPTIONS" + ) + } catch (e: EmvTransactionException) { + // Add PDOL context for debugging + val pdolInfo = if (pdol != null) { + val elements = parseDol(pdol) + "PDOL requested: ${elements.joinToString { "${it.first}(${it.second})" }}" + } else { + "No PDOL requested" + } + throw EmvTransactionException( + "${e.message}. $pdolInfo", + e.userMessage, + e.statusWord + ) + } + val gpoData = EmvApduCommands.getResponseData(gpoResponse) + allTlvData.putAll(TlvParser.parse(gpoData)) + + // Check for Track 2 in GPO Format 1 response (Visa qVSDC) + cardDataExtractor.extractTrack2FromGpo(gpoResponse)?.let { track2 -> + allTlvData[CardDataExtractor.TAG_TRACK2] = track2 + } + + // Check for digital wallet BEFORE reading full card data + if (digitalWalletDetector.isDigitalWallet(allTlvData, aid)) { + throw DigitalWalletNotSupportedException() + } + + // Extract AFL (Application File Locator) + val afl = cardDataExtractor.extractAfl(gpoResponse) + + // Step 4: READ RECORD(s) if AFL is present + if (afl != null) { + readAllRecords(isoDep, afl, allTlvData) + } + + // Extract card data from accumulated TLV data (GPO + all records) + return cardDataExtractor.extractFromTlvMap(allTlvData, aid) + } + + /** + * Read all records specified by the AFL. + */ + private fun readAllRecords( + isoDep: IsoDep, + afl: ByteArray, + allTlvData: MutableMap + ): ByteArray { + val aflEntries = EmvApduCommands.parseAfl(afl) + val recordData = mutableListOf() + + for ((sfi, firstRecord, lastRecord) in aflEntries) { + for (recordNum in firstRecord..lastRecord) { + try { + val readRecordCmd = EmvApduCommands.readRecord(sfi, recordNum) + val response = transceive(isoDep, readRecordCmd, "READ RECORD $sfi:$recordNum") + val data = EmvApduCommands.getResponseData(response) + + // Add to all TLV data for any additional checks + allTlvData.putAll(TlvParser.parse(data)) + + // Accumulate record data + recordData.addAll(data.toList()) + } catch (e: EmvTransactionException) { + // Some records may not exist, continue to next + if (e.statusWord != SW_RECORD_NOT_FOUND && e.statusWord != SW_REFERENCED_DATA_NOT_FOUND) { + throw e + } + } + } + } + + return recordData.toByteArray() + } + + /** + * Build PDOL data from PDOL template. + * + * The PDOL specifies what data objects the card needs for GET PROCESSING OPTIONS. + * We provide sensible default values for common EMV tags. + */ + private fun buildPdolData(pdol: ByteArray?): ByteArray? { + if (pdol == null || pdol.isEmpty()) return null + + // Parse PDOL to get tag-length pairs + val pdolElements = parseDol(pdol) + + // Build response with appropriate values for each tag + val result = mutableListOf() + for ((tag, length) in pdolElements) { + val value = getDefaultValueForTag(tag, length) + result.addAll(value.toList()) + } + + return result.toByteArray() + } + + /** + * Get default value for a PDOL tag. + * + * Common PDOL tags and their meanings: + * - 9F66: Terminal Transaction Qualifiers (TTQ) - critical for contactless + * - 9F02: Amount Authorized + * - 9F03: Amount Other + * - 9F1A: Terminal Country Code + * - 5F2A: Transaction Currency Code + * - 9A: Transaction Date + * - 9C: Transaction Type + * - 9F37: Unpredictable Number + * + * Amex Expresspay specific: + * - 9F6C: Card Transaction Qualifiers (CTQ) + * - 9F7A: VLP Terminal Support Indicator + * - 9F6E: Enhanced Contactless Reader Capabilities + */ + private fun getDefaultValueForTag(tag: String, length: Int): ByteArray { + return when (tag.uppercase()) { + // Terminal Transaction Qualifiers (TTQ) - 4 bytes + // Byte 1: + // Bit 8: MSD supported = 0 + // Bit 7: qVSDC supported = 1 + // Bit 6: EMV mode supported = 1 + // Bit 5: EMV contact chip supported = 0 + // Bit 4: Offline-only reader = 0 + // Bit 3: Online PIN supported = 0 + // Bit 2: Signature supported = 0 + // Bit 1: ODA for online auth supported = 1 + // Byte 2: + // Bit 8: Online cryptogram required = 1 + // Bit 7: CVM required = 0 + // Bit 6: Contact chip offline PIN supported = 0 + // This tells the card we support contactless EMV and want online auth + "9F66" -> byteArrayOf(0x36, 0x80.toByte(), 0x00, 0x00).take(length).toByteArray() + + // Amount Authorized - typically 6 bytes BCD + // Use small amount (1.00) to avoid CVM requirements + "9F02" -> byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x01, 0x00).take(length).toByteArray() + + // Amount Other - 6 bytes BCD (cashback, etc.) + "9F03" -> ByteArray(length) + + // Terminal Country Code - 2 bytes (840 = USA) + "9F1A" -> byteArrayOf(0x08, 0x40).take(length).toByteArray() + + // Transaction Currency Code - 2 bytes (840 = USD) + "5F2A" -> byteArrayOf(0x08, 0x40).take(length).toByteArray() + + // Transaction Date - 3 bytes YYMMDD BCD + "9A" -> getCurrentDateBcd().take(length).toByteArray() + + // Transaction Type - 1 byte (00 = purchase) + "9C" -> byteArrayOf(0x00).take(length).toByteArray() + + // Unpredictable Number - 4 bytes random + "9F37" -> generateUnpredictableNumber(length) + + // Terminal Type - 1 byte (22 = attended, online only) + "9F35" -> byteArrayOf(0x22).take(length).toByteArray() + + // Terminal Capabilities - 3 bytes + "9F33" -> byteArrayOf(0xE0.toByte(), 0xF0.toByte(), 0xC8.toByte()).take(length).toByteArray() + + // Additional Terminal Capabilities - 5 bytes + "9F40" -> byteArrayOf(0x60, 0x00, 0x00, 0x00, 0x00).take(length).toByteArray() + + // === Amex Expresspay specific tags === + + // Card Transaction Qualifiers (CTQ) - 2 bytes (Amex) + // Byte 1: Online PIN required = 0, Signature required = 0 + // Byte 2: Go online = 1, Switch interface = 0 + "9F6C" -> byteArrayOf(0x00, 0x80.toByte()).take(length).toByteArray() + + // VLP Terminal Support Indicator - 1 byte (Amex) + // 01 = VLP (contactless) supported + "9F7A" -> byteArrayOf(0x01).take(length).toByteArray() + + // Enhanced Contactless Reader Capabilities - 4 bytes (Amex) + // Indicates terminal supports contactless EMV + "9F6E" -> byteArrayOf( + 0xD8.toByte(), // Contactless supported, online capable + 0x40, // Support for EMV mode + 0x00, + 0x00 + ).take(length).toByteArray() + + // Mobile Support Indicator - 1 byte (Amex) + // 01 = Mobile/NFC supported + "9F7E" -> byteArrayOf(0x01).take(length).toByteArray() + + // Transaction Time - 3 bytes HHMMSS BCD + "9F21" -> getCurrentTimeBcd().take(length).toByteArray() + + // Point of Service Entry Mode - 1 byte + // 07 = Contactless chip + "9F39" -> byteArrayOf(0x07).take(length).toByteArray() + + // Default: return zeros for unknown tags + else -> ByteArray(length) + } + } + + private fun getCurrentDateBcd(): ByteArray { + val calendar = java.util.Calendar.getInstance() + val year = calendar.get(java.util.Calendar.YEAR) % 100 + val month = calendar.get(java.util.Calendar.MONTH) + 1 + val day = calendar.get(java.util.Calendar.DAY_OF_MONTH) + return byteArrayOf( + ((year / 10) shl 4 or (year % 10)).toByte(), + ((month / 10) shl 4 or (month % 10)).toByte(), + ((day / 10) shl 4 or (day % 10)).toByte() + ) + } + + private fun getCurrentTimeBcd(): ByteArray { + val calendar = java.util.Calendar.getInstance() + val hour = calendar.get(java.util.Calendar.HOUR_OF_DAY) + val minute = calendar.get(java.util.Calendar.MINUTE) + val second = calendar.get(java.util.Calendar.SECOND) + return byteArrayOf( + ((hour / 10) shl 4 or (hour % 10)).toByte(), + ((minute / 10) shl 4 or (minute % 10)).toByte(), + ((second / 10) shl 4 or (second % 10)).toByte() + ) + } + + private fun generateUnpredictableNumber(length: Int): ByteArray { + return ByteArray(length).also { java.security.SecureRandom().nextBytes(it) } + } + + /** + * Parse a Data Object List (DOL) to get tag-length pairs. + */ + private fun parseDol(dol: ByteArray): List> { + val result = mutableListOf>() + var offset = 0 + + while (offset < dol.size) { + // Parse tag (1 or 2 bytes) + val firstByte = dol[offset].toInt() and 0xFF + val tagLength = if ((firstByte and 0x1F) == 0x1F) 2 else 1 + val tag = dol.copyOfRange(offset, offset + tagLength) + .joinToString("") { String.format("%02X", it) } + offset += tagLength + + // Parse length + if (offset >= dol.size) break + val length = dol[offset].toInt() and 0xFF + offset++ + + result.add(tag to length) + } + + return result + } + + /** + * Send APDU command and verify response. + */ + private fun transceive(isoDep: IsoDep, command: ByteArray, commandName: String): ByteArray { + val response = isoDep.transceive(command) + + if (!EmvApduCommands.isSuccess(response)) { + val sw = EmvApduCommands.getStatusWord(response) + throw EmvTransactionException( + "$commandName failed with status ${String.format("%04X", sw)}", + "Card communication error. Please try again.", + sw + ) + } + + return response + } + + /** + * Create PaymentMethod from extracted card data. + * + * Note: For this POC, we generate a local ID. In production, this should + * call the Stripe API to create a real PaymentMethod. However, that would + * require additional work since: + * 1. We don't have CVC from NFC (not stored on chip) + * 2. Raw PAN handling requires careful PCI consideration + * 3. May need a server-side component for secure tokenization + */ + private fun createPaymentMethod(cardData: CardDataExtractor.CardData): PaymentMethod { + // Generate a local ID for POC purposes + // Format: pm_nfc_ + val localId = "pm_nfc_${java.util.UUID.randomUUID().toString().replace("-", "").take(24)}" + + return PaymentMethod.Builder() + .setId(localId) + .setCode(PaymentMethod.Type.Card.code) + .setType(PaymentMethod.Type.Card) + .setCard( + PaymentMethod.Card( + last4 = cardData.last4, + brand = cardData.scheme.toCardBrand(), + expiryMonth = cardData.expiryMonth, + expiryYear = cardData.expiryYear, + ) + ) + .setCreated(System.currentTimeMillis() / 1000) + .setLiveMode(false) + .build() + } + + private fun CardDataExtractor.CardScheme.toCardBrand(): CardBrand { + return when (this) { + CardDataExtractor.CardScheme.VISA -> CardBrand.Visa + CardDataExtractor.CardScheme.MASTERCARD -> CardBrand.MasterCard + CardDataExtractor.CardScheme.AMEX -> CardBrand.AmericanExpress + CardDataExtractor.CardScheme.DISCOVER -> CardBrand.Discover + CardDataExtractor.CardScheme.JCB -> CardBrand.JCB + CardDataExtractor.CardScheme.UNIONPAY -> CardBrand.UnionPay + CardDataExtractor.CardScheme.UNKNOWN -> CardBrand.Unknown + } + } + + companion object { + // Status words for record not found conditions + private const val SW_RECORD_NOT_FOUND = 0x6A83 + private const val SW_REFERENCED_DATA_NOT_FOUND = 0x6A88 + } +} + +/** + * Exception for EMV transaction errors. + */ +internal class EmvTransactionException( + message: String, + val userMessage: String, + val statusWord: Int = 0 +) : Exception(message) diff --git a/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/NfcDirectConnectionManager.kt b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/NfcDirectConnectionManager.kt new file mode 100644 index 00000000000..2dab30eb220 --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/NfcDirectConnectionManager.kt @@ -0,0 +1,202 @@ +package com.stripe.android.common.taptoadd.nfcdirect + +import android.app.Activity +import android.content.Context +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.IsoDep +import android.os.Build +import android.os.Bundle +import androidx.annotation.RequiresApi +import com.stripe.android.common.taptoadd.TapToAddConnectionManager +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext + +/** + * NFC connection manager that uses Android's NFC Reader Mode API directly. + * + * This provides a lightweight alternative to the Stripe Terminal SDK for + * Card Not Present (CNP) card data reading scenarios like "Tap to Add". + * + * Key features: + * - Uses Android's NfcAdapter.enableReaderMode() for exclusive NFC access + * - Supports IsoDep (ISO 14443-4) for EMV contactless communication + * - Configures appropriate timeouts for card communication + * + * Note: Requires Activity to be set via [NfcDirectActivityHolder.set] before + * calling [connect]. This is necessary because NFC Reader Mode requires an + * Activity reference. + */ +@RequiresApi(Build.VERSION_CODES.KITKAT) +internal class NfcDirectConnectionManager( + context: Context, + private val workContext: CoroutineContext, +) : TapToAddConnectionManager, NfcAdapter.ReaderCallback { + + private val nfcAdapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(context) + private val workScope = CoroutineScope(workContext) + + private var tagDeferred: CompletableDeferred? = null + private var connectionTask: CompletableDeferred? = null + private var currentIsoDep: IsoDep? = null + private var currentActivity: Activity? = null + + override val isSupported: Boolean + get() = nfcAdapter?.isEnabled == true && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT + + override val isConnected: Boolean + get() = currentIsoDep?.isConnected == true + + /** + * Start listening for NFC card taps. + * + * Enables Reader Mode which gives us exclusive access to the NFC hardware + * and prevents the system from dispatching NFC intents to other apps. + * + * Note: [NfcDirectActivityHolder.set] must be called before this method. + */ + override fun connect() { + // Initialize deferred objects synchronously to avoid race conditions + // where awaitConnection() or awaitTag() is called before the coroutine runs + synchronized(this) { + if (!isSupported || connectionTask?.isActive == true) { + return + } + tagDeferred = CompletableDeferred() + connectionTask = CompletableDeferred() + } + + workScope.launch { + val activity = NfcDirectActivityHolder.get() + if (activity == null) { + connectionTask?.completeExceptionally( + IllegalStateException( + "Activity not available. Call NfcDirectActivityHolder.set() first." + ) + ) + return@launch + } + + currentActivity = activity + + try { + enableReaderMode(activity) + } catch (e: Exception) { + connectionTask?.completeExceptionally(e) + } + } + } + + override suspend fun awaitConnection(): Result { + return runCatching { + isConnected || connectionTask?.await() ?: false + } + } + + /** + * Called by NfcAdapter when a tag is discovered. + * + * We check for IsoDep technology (ISO 14443-4) which is required for + * EMV contactless transactions. + */ + override fun onTagDiscovered(tag: Tag) { + val isoDep = IsoDep.get(tag) + if (isoDep == null) { + // Not an ISO-DEP tag, ignore + return + } + + workScope.launch { + try { + isoDep.connect() + // Set extended timeout for slow cards (some contactless cards need this) + isoDep.timeout = ISODEP_TIMEOUT_MS + + currentIsoDep = isoDep + tagDeferred?.complete(isoDep) + connectionTask?.complete(true) + } catch (e: Exception) { + tagDeferred?.completeExceptionally(e) + connectionTask?.completeExceptionally(e) + } + } + } + + /** + * Wait for a card to be tapped and return the IsoDep interface for communication. + */ + suspend fun awaitTag(): IsoDep { + return tagDeferred?.await() + ?: throw IllegalStateException("connect() must be called before awaitTag()") + } + + /** + * Get the currently connected IsoDep interface. + */ + fun getCurrentIsoDep(): IsoDep? = currentIsoDep + + /** + * Disable NFC reader mode and clean up. + */ + fun disconnect() { + try { + currentIsoDep?.close() + } catch (_: Exception) { + // Ignore close errors + } + currentIsoDep = null + + currentActivity?.let { activity -> + try { + nfcAdapter?.disableReaderMode(activity) + } catch (_: Exception) { + // Ignore disable errors (activity may be finishing) + } + } + currentActivity = null + + // Reset deferred objects for next connection + tagDeferred = null + connectionTask = null + } + + /** + * Reset for a new card tap (after successful or failed read). + */ + fun resetForNextTap() { + disconnect() + synchronized(this) { + tagDeferred = CompletableDeferred() + connectionTask = CompletableDeferred() + } + workScope.launch { + NfcDirectActivityHolder.get()?.let { enableReaderMode(it) } + } + } + + private fun enableReaderMode(activity: Activity) { + val flags = NfcAdapter.FLAG_READER_NFC_A or + NfcAdapter.FLAG_READER_NFC_B or + NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK + + val options = Bundle().apply { + // Disable presence check to improve performance + putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, PRESENCE_CHECK_DELAY_MS) + } + + nfcAdapter?.enableReaderMode(activity, this, flags, options) + } + + companion object { + // Timeout for ISO-DEP communication (milliseconds) + // Some contactless cards need extended timeout + private const val ISODEP_TIMEOUT_MS = 5000 + + // Delay between presence checks (milliseconds) + // Longer delay reduces overhead when card is held against device + private const val PRESENCE_CHECK_DELAY_MS = 500 + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/TlvParser.kt b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/TlvParser.kt new file mode 100644 index 00000000000..62a690479e7 --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/nfcdirect/TlvParser.kt @@ -0,0 +1,205 @@ +package com.stripe.android.common.taptoadd.nfcdirect + +/** + * BER-TLV parser for EMV contactless card data. + * Parses Tag-Length-Value structures as defined in EMV specifications. + */ +internal object TlvParser { + + /** + * Parsed TLV data element. + */ + data class TlvElement( + val tag: String, + val value: ByteArray, + val isConstructed: Boolean = false, + val children: List = emptyList() + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TlvElement) return false + return tag == other.tag && value.contentEquals(other.value) + } + + override fun hashCode(): Int { + return 31 * tag.hashCode() + value.contentHashCode() + } + } + + /** + * Parse BER-TLV encoded data into a flat map of tag -> value. + * Recursively parses constructed tags. + */ + fun parse(data: ByteArray): Map { + val result = mutableMapOf() + parseRecursive(data, 0, data.size, result) + return result + } + + /** + * Parse BER-TLV data and return structured elements. + */ + fun parseToElements(data: ByteArray): List { + return parseElements(data, 0, data.size) + } + + private fun parseRecursive( + data: ByteArray, + startOffset: Int, + endOffset: Int, + result: MutableMap + ) { + var offset = startOffset + + while (offset < endOffset) { + // Parse tag + val tagResult = parseTag(data, offset) + if (tagResult == null) break + val (tag, tagLength) = tagResult + offset += tagLength + + if (offset >= endOffset) break + + // Parse length + val lengthResult = parseLength(data, offset) + if (lengthResult == null) break + val (length, lengthBytes) = lengthResult + offset += lengthBytes + + if (offset + length > endOffset) break + + // Extract value + val value = data.copyOfRange(offset, offset + length) + result[tag] = value + + // If constructed tag, parse children + if (isConstructedTag(data[offset - tagLength - lengthBytes].toInt() and 0xFF)) { + parseRecursive(value, 0, value.size, result) + } + + offset += length + } + } + + private fun parseElements( + data: ByteArray, + startOffset: Int, + endOffset: Int + ): List { + val elements = mutableListOf() + var offset = startOffset + + while (offset < endOffset) { + val tagResult = parseTag(data, offset) ?: break + val (tag, tagLength) = tagResult + val tagFirstByte = data[offset].toInt() and 0xFF + offset += tagLength + + if (offset >= endOffset) break + + val lengthResult = parseLength(data, offset) ?: break + val (length, lengthBytes) = lengthResult + offset += lengthBytes + + if (offset + length > endOffset) break + + val value = data.copyOfRange(offset, offset + length) + val isConstructed = isConstructedTag(tagFirstByte) + + val element = if (isConstructed) { + TlvElement( + tag = tag, + value = value, + isConstructed = true, + children = parseElements(value, 0, value.size) + ) + } else { + TlvElement(tag = tag, value = value) + } + + elements.add(element) + offset += length + } + + return elements + } + + private fun parseTag(data: ByteArray, offset: Int): Pair? { + if (offset >= data.size) return null + + val firstByte = data[offset].toInt() and 0xFF + + // Skip padding bytes (00 or FF) + if (firstByte == 0x00 || firstByte == 0xFF) { + return null + } + + // Check if multi-byte tag (bits 1-5 all set) + return if ((firstByte and 0x1F) == 0x1F) { + // Multi-byte tag + var tagLength = 1 + while (offset + tagLength < data.size) { + val nextByte = data[offset + tagLength].toInt() and 0xFF + tagLength++ + // If bit 8 is not set, this is the last byte + if ((nextByte and 0x80) == 0) break + } + val tagBytes = data.copyOfRange(offset, offset + tagLength) + tagBytes.toHexString() to tagLength + } else { + // Single byte tag + String.format("%02X", firstByte) to 1 + } + } + + private fun parseLength(data: ByteArray, offset: Int): Pair? { + if (offset >= data.size) return null + + val firstByte = data[offset].toInt() and 0xFF + + return if ((firstByte and 0x80) == 0) { + // Short form: length is in bits 1-7 + firstByte to 1 + } else { + // Long form: bits 1-7 indicate number of subsequent length bytes + val numBytes = firstByte and 0x7F + if (numBytes == 0 || offset + 1 + numBytes > data.size) { + return null + } + var length = 0 + for (i in 1..numBytes) { + length = (length shl 8) or (data[offset + i].toInt() and 0xFF) + } + length to (1 + numBytes) + } + } + + private fun isConstructedTag(firstByte: Int): Boolean { + // Bit 6 indicates constructed (1) vs primitive (0) + return (firstByte and 0x20) != 0 + } + + /** + * Find a specific tag in parsed TLV data. + */ + fun findTag(data: ByteArray, targetTag: String): ByteArray? { + return parse(data)[targetTag.uppercase()] + } + + /** + * Convert ByteArray to hex string. + */ + fun ByteArray.toHexString(): String { + return joinToString("") { String.format("%02X", it) } + } + + /** + * Convert hex string to ByteArray. + */ + fun String.hexToByteArray(): ByteArray { + val cleanHex = this.replace(" ", "").uppercase() + return ByteArray(cleanHex.length / 2) { i -> + cleanHex.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetActivity.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetActivity.kt index ba882ca1d3d..46fe5798aae 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetActivity.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentSheetActivity.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.getValue import androidx.lifecycle.ViewModelProvider import com.stripe.android.PaymentConfiguration import com.stripe.android.common.model.asCommonConfiguration +import com.stripe.android.common.taptoadd.nfcdirect.NfcDirectActivityHolder import com.stripe.android.common.ui.ElementsBottomSheetLayout import com.stripe.android.paymentsheet.ui.BaseSheetActivity import com.stripe.android.paymentsheet.ui.PaymentSheetScreen @@ -38,6 +39,9 @@ internal class PaymentSheetActivity : BaseSheetActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Set activity for NFC Direct card reading + NfcDirectActivityHolder.set(this, this) + val starterArgs = this.starterArgs if (starterArgs == null) { finishWithError(defaultInitializationError()) diff --git a/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/nfcdirect/CardDataExtractorTest.kt b/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/nfcdirect/CardDataExtractorTest.kt new file mode 100644 index 00000000000..7cf6ccc3795 --- /dev/null +++ b/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/nfcdirect/CardDataExtractorTest.kt @@ -0,0 +1,165 @@ +package com.stripe.android.common.taptoadd.nfcdirect + +import com.google.common.truth.Truth.assertThat +import com.stripe.android.common.taptoadd.nfcdirect.TlvParser.hexToByteArray +import org.junit.Test + +class CardDataExtractorTest { + + private val extractor = CardDataExtractor() + + @Test + fun `extract card data from PAN and expiry tags`() { + // Tag 5A (PAN) + Tag 5F24 (Expiry YYMMDD) + val records = ("5A084111111111111111" + "5F2403261231").hexToByteArray() + val aid = "A0000000031010".hexToByteArray() // Visa + + val cardData = extractor.extract(records, aid) + + assertThat(cardData.pan).isEqualTo("4111111111111111") + assertThat(cardData.expiryMonth).isEqualTo(12) + assertThat(cardData.expiryYear).isEqualTo(2026) + assertThat(cardData.scheme).isEqualTo(CardDataExtractor.CardScheme.VISA) + } + + @Test + fun `extract card data from Track 2`() { + // Tag 57 (Track 2): PAN D YYMM ServiceCode + val records = "57124111111111111111D2612201123456789".hexToByteArray() + val aid = "A0000000041010".hexToByteArray() // Mastercard + + val cardData = extractor.extract(records, aid) + + assertThat(cardData.pan).isEqualTo("4111111111111111") + assertThat(cardData.expiryMonth).isEqualTo(12) + assertThat(cardData.expiryYear).isEqualTo(2026) + assertThat(cardData.scheme).isEqualTo(CardDataExtractor.CardScheme.MASTERCARD) + } + + @Test + fun `extract cardholder name`() { + val records = ("5A084111111111111111" + + "5F2403261231" + + "5F200E4A4F484E20444F452F4D522E20").hexToByteArray() + val aid = "A0000000031010".hexToByteArray() + + val cardData = extractor.extract(records, aid) + + assertThat(cardData.cardholderName).isEqualTo("JOHN DOE/MR.") + } + + @Test + fun `last4 property returns last 4 digits`() { + val records = "5A084111111111111234".hexToByteArray() + val aid = "A0000000031010".hexToByteArray() + + // Need valid expiry too + val fullRecords = (records.toList() + "5F2403261231".hexToByteArray().toList()).toByteArray() + val cardData = extractor.extract(fullRecords, aid) + + assertThat(cardData.last4).isEqualTo("1234") + } + + @Test + fun `detect Visa scheme from AID`() { + val records = ("5A084111111111111111" + "5F2403261231").hexToByteArray() + val aid = "A0000000031010".hexToByteArray() + + val cardData = extractor.extract(records, aid) + + assertThat(cardData.scheme).isEqualTo(CardDataExtractor.CardScheme.VISA) + } + + @Test + fun `detect Mastercard scheme from AID`() { + val records = ("5A085555555555554444" + "5F2403261231").hexToByteArray() + val aid = "A0000000041010".hexToByteArray() + + val cardData = extractor.extract(records, aid) + + assertThat(cardData.scheme).isEqualTo(CardDataExtractor.CardScheme.MASTERCARD) + } + + @Test + fun `detect Amex scheme from AID`() { + val records = ("5A08378282246310005" + "5F2403261231").hexToByteArray() + val aid = "A00000002501".hexToByteArray() + + val cardData = extractor.extract(records, aid) + + assertThat(cardData.scheme).isEqualTo(CardDataExtractor.CardScheme.AMEX) + } + + @Test + fun `detect Discover scheme from AID`() { + val records = ("5A086011111111111117" + "5F2403261231").hexToByteArray() + val aid = "A0000001523010".hexToByteArray() + + val cardData = extractor.extract(records, aid) + + assertThat(cardData.scheme).isEqualTo(CardDataExtractor.CardScheme.DISCOVER) + } + + @Test(expected = CardDataExtractionException::class) + fun `throws when PAN not found`() { + val records = "5F2403261231".hexToByteArray() // Only expiry, no PAN + val aid = "A0000000031010".hexToByteArray() + + extractor.extract(records, aid) + } + + @Test(expected = CardDataExtractionException::class) + fun `throws when expiry not found`() { + val records = "5A084111111111111111".hexToByteArray() // Only PAN, no expiry + val aid = "A0000000031010".hexToByteArray() + + extractor.extract(records, aid) + } + + @Test + fun `extractAid finds AID in PPSE response`() { + // Simulated PPSE response with Visa AID + val ppseResponse = ("6F1A840E325041592E5359532E4444463031A5088801015F2D02656E" + + "9000").hexToByteArray() + + // This is a simplified test - real PPSE parsing would need more data + val aid = extractor.extractAid(ppseResponse) + + // Should find the AID + assertThat(aid).isNotNull() + } + + @Test + fun `extractPdol returns PDOL from SELECT response`() { + // Simulated SELECT AID response with PDOL (9F38) + val selectResponse = ("6F208407A0000000031010A515500A564953412044454249549F380B" + + "9F66049F02069F37049000").hexToByteArray() + + val pdol = extractor.extractPdol(selectResponse) + + // PDOL should be present (contains 9F66, 9F02, 9F37) + assertThat(pdol).isNotNull() + } + + @Test + fun `extractAfl finds AFL in GPO response`() { + // GPO Format 2 response with AFL + val gpoResponse = "770E820219009408080101001001039000".hexToByteArray() + + val afl = extractor.extractAfl(gpoResponse) + + assertThat(afl).isNotNull() + } + + @Test + fun `handles PAN with padding F`() { + // PAN with trailing F padding (odd length PAN) + val records = ("5A084111111111111F" + "5F2403261231").hexToByteArray() + val aid = "A0000000031010".hexToByteArray() + + val cardData = extractor.extract(records, aid) + + // Should strip the F padding + assertThat(cardData.pan).isEqualTo("411111111111") + } +} diff --git a/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/nfcdirect/DigitalWalletDetectorTest.kt b/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/nfcdirect/DigitalWalletDetectorTest.kt new file mode 100644 index 00000000000..c2d82bffe6f --- /dev/null +++ b/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/nfcdirect/DigitalWalletDetectorTest.kt @@ -0,0 +1,162 @@ +package com.stripe.android.common.taptoadd.nfcdirect + +import com.google.common.truth.Truth.assertThat +import com.stripe.android.common.taptoadd.nfcdirect.TlvParser.hexToByteArray +import org.junit.Test + +class DigitalWalletDetectorTest { + + private val detector = DigitalWalletDetector() + + @Test + fun `detects Mastercard digital wallet from CVM Results`() { + // Mastercard AID + val aid = "A0000000041010".hexToByteArray() + + // CVM Results (9F34) with mobile/CDCVM indicator (byte 1 = 0x1F) + val tlvData = mapOf( + "9F34" to byteArrayOf(0x1F, 0x00, 0x00) + ) + + assertThat(detector.isDigitalWallet(tlvData, aid)).isTrue() + } + + @Test + fun `does not detect physical Mastercard`() { + val aid = "A0000000041010".hexToByteArray() + + // CVM Results without mobile indicator + val tlvData = mapOf( + "9F34" to byteArrayOf(0x02, 0x00, 0x00) // PIN verified + ) + + assertThat(detector.isDigitalWallet(tlvData, aid)).isFalse() + } + + @Test + fun `detects Visa digital wallet from CTQ`() { + // Visa AID + val aid = "A0000000031010".hexToByteArray() + + // Card Transaction Qualifiers (C1) with mobile indicators + val tlvData = mapOf( + "C1" to byteArrayOf(0xC0.toByte(), 0x00) // Both bits 7 and 8 set + ) + + assertThat(detector.isDigitalWallet(tlvData, aid)).isTrue() + } + + @Test + fun `does not detect physical Visa card`() { + val aid = "A0000000031010".hexToByteArray() + + // CTQ without mobile indicators or no CTQ at all + val tlvData = mapOf( + "C1" to byteArrayOf(0x00, 0x00) + ) + + assertThat(detector.isDigitalWallet(tlvData, aid)).isFalse() + } + + @Test + fun `does not detect Visa without CTQ tag`() { + val aid = "A0000000031010".hexToByteArray() + + // No CTQ tag present (physical card) + val tlvData = emptyMap() + + assertThat(detector.isDigitalWallet(tlvData, aid)).isFalse() + } + + @Test + fun `detects Amex digital wallet from enhanced capabilities`() { + // Amex AID + val aid = "A00000002501".hexToByteArray() + + // Enhanced Contactless Reader Capabilities (9F71) with mobile indicator + val tlvData = mapOf( + "9F71" to byteArrayOf(0x01, 0x00, 0x00) + ) + + assertThat(detector.isDigitalWallet(tlvData, aid)).isTrue() + } + + @Test + fun `does not detect physical Amex card`() { + val aid = "A00000002501".hexToByteArray() + + // No mobile indicator + val tlvData = mapOf( + "9F71" to byteArrayOf(0x00, 0x00, 0x00) + ) + + assertThat(detector.isDigitalWallet(tlvData, aid)).isFalse() + } + + @Test + fun `detects Discover digital wallet from IAD`() { + // Discover AID + val aid = "A0000001523010".hexToByteArray() + + // Issuer Application Data (9F10) with DPAN indicator + val tlvData = mapOf( + "9F10" to byteArrayOf(0x01, 0x00, 0x00, 0x00) + ) + + assertThat(detector.isDigitalWallet(tlvData, aid)).isTrue() + } + + @Test + fun `does not detect physical Discover card`() { + val aid = "A0000001523010".hexToByteArray() + + // IAD without DPAN indicator + val tlvData = mapOf( + "9F10" to byteArrayOf(0x00, 0x00, 0x00, 0x00) + ) + + assertThat(detector.isDigitalWallet(tlvData, aid)).isFalse() + } + + @Test + fun `returns false for unknown scheme`() { + val aid = "A000000099".hexToByteArray() // Unknown AID + + val tlvData = mapOf( + "9F34" to byteArrayOf(0x1F, 0x00, 0x00) + ) + + // Should return false (conservative, allow unknown schemes) + assertThat(detector.isDigitalWallet(tlvData, aid)).isFalse() + } + + @Test + fun `handles empty TLV data`() { + val aid = "A0000000041010".hexToByteArray() + + assertThat(detector.isDigitalWallet(emptyMap(), aid)).isFalse() + } + + @Test + fun `Mastercard CVM byte with bit 4 set indicates mobile`() { + val aid = "A0000000041010".hexToByteArray() + + // Bit 4 (0x08) set indicates mobile + val tlvData = mapOf( + "9F34" to byteArrayOf(0x08, 0x00, 0x00) + ) + + assertThat(detector.isDigitalWallet(tlvData, aid)).isTrue() + } + + @Test + fun `Mastercard CVM 0x1E indicates mobile`() { + val aid = "A0000000041010".hexToByteArray() + + val tlvData = mapOf( + "9F34" to byteArrayOf(0x1E, 0x00, 0x00) + ) + + assertThat(detector.isDigitalWallet(tlvData, aid)).isTrue() + } +} diff --git a/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/nfcdirect/EmvApduCommandsTest.kt b/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/nfcdirect/EmvApduCommandsTest.kt new file mode 100644 index 00000000000..073fbbf7cac --- /dev/null +++ b/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/nfcdirect/EmvApduCommandsTest.kt @@ -0,0 +1,180 @@ +package com.stripe.android.common.taptoadd.nfcdirect + +import com.google.common.truth.Truth.assertThat +import com.stripe.android.common.taptoadd.nfcdirect.TlvParser.toHexString +import org.junit.Test + +class EmvApduCommandsTest { + + @Test + fun `SELECT_PPSE command is correct`() { + val cmd = EmvApduCommands.SELECT_PPSE + + // Should be: 00 A4 04 00 0E 32504159...00 + assertThat(cmd[0]).isEqualTo(0x00.toByte()) // CLA + assertThat(cmd[1]).isEqualTo(0xA4.toByte()) // INS: SELECT + assertThat(cmd[2]).isEqualTo(0x04.toByte()) // P1: Select by name + assertThat(cmd[3]).isEqualTo(0x00.toByte()) // P2: First occurrence + assertThat(cmd[4]).isEqualTo(0x0E.toByte()) // Lc: 14 bytes + + // Name: "2PAY.SYS.DDF01" + val name = String(cmd.sliceArray(5..18), Charsets.US_ASCII) + assertThat(name).isEqualTo("2PAY.SYS.DDF01") + + // Le + assertThat(cmd.last()).isEqualTo(0x00.toByte()) + } + + @Test + fun `selectAid generates correct command for Visa`() { + val cmd = EmvApduCommands.selectAid(EmvApduCommands.Aids.VISA) + + assertThat(cmd[0]).isEqualTo(0x00.toByte()) // CLA + assertThat(cmd[1]).isEqualTo(0xA4.toByte()) // INS: SELECT + assertThat(cmd[2]).isEqualTo(0x04.toByte()) // P1 + assertThat(cmd[3]).isEqualTo(0x00.toByte()) // P2 + assertThat(cmd[4]).isEqualTo(0x07.toByte()) // Lc: 7 bytes for Visa AID + + // Visa AID + val aid = cmd.sliceArray(5..11).toHexString() + assertThat(aid).isEqualTo("A0000000031010") + } + + @Test + fun `selectAid generates correct command for Mastercard`() { + val cmd = EmvApduCommands.selectAid(EmvApduCommands.Aids.MASTERCARD) + + val aid = cmd.sliceArray(5..11).toHexString() + assertThat(aid).isEqualTo("A0000000041010") + } + + @Test + fun `getProcessingOptions with no PDOL`() { + val cmd = EmvApduCommands.getProcessingOptions(null) + + assertThat(cmd[0]).isEqualTo(0x80.toByte()) // CLA: EMV + assertThat(cmd[1]).isEqualTo(0xA8.toByte()) // INS: GPO + assertThat(cmd[2]).isEqualTo(0x00.toByte()) // P1 + assertThat(cmd[3]).isEqualTo(0x00.toByte()) // P2 + assertThat(cmd[4]).isEqualTo(0x02.toByte()) // Lc: 2 (empty PDOL wrapper) + assertThat(cmd[5]).isEqualTo(0x83.toByte()) // Tag 83 + assertThat(cmd[6]).isEqualTo(0x00.toByte()) // Length 0 + } + + @Test + fun `getProcessingOptions with PDOL data`() { + val pdolData = byteArrayOf(0x00, 0x00, 0x00, 0x00) // 4 bytes of PDOL data + val cmd = EmvApduCommands.getProcessingOptions(pdolData) + + assertThat(cmd[0]).isEqualTo(0x80.toByte()) // CLA: EMV + assertThat(cmd[1]).isEqualTo(0xA8.toByte()) // INS: GPO + assertThat(cmd[4]).isEqualTo(0x06.toByte()) // Lc: 6 (tag + length + 4 data) + assertThat(cmd[5]).isEqualTo(0x83.toByte()) // Tag 83 + assertThat(cmd[6]).isEqualTo(0x04.toByte()) // Length 4 + } + + @Test + fun `readRecord generates correct command`() { + val cmd = EmvApduCommands.readRecord(sfi = 1, recordNumber = 1) + + assertThat(cmd[0]).isEqualTo(0x00.toByte()) // CLA + assertThat(cmd[1]).isEqualTo(0xB2.toByte()) // INS: READ RECORD + assertThat(cmd[2]).isEqualTo(0x01.toByte()) // P1: Record 1 + // P2: SFI 1 shifted left by 3, plus 0x04 + assertThat(cmd[3]).isEqualTo(0x0C.toByte()) // (1 << 3) | 0x04 = 12 + assertThat(cmd[4]).isEqualTo(0x00.toByte()) // Le + } + + @Test + fun `readRecord with different SFI and record`() { + val cmd = EmvApduCommands.readRecord(sfi = 2, recordNumber = 3) + + assertThat(cmd[2]).isEqualTo(0x03.toByte()) // P1: Record 3 + assertThat(cmd[3]).isEqualTo(0x14.toByte()) // P2: (2 << 3) | 0x04 = 20 + } + + @Test(expected = IllegalArgumentException::class) + fun `readRecord rejects invalid SFI`() { + EmvApduCommands.readRecord(sfi = 31, recordNumber = 1) + } + + @Test(expected = IllegalArgumentException::class) + fun `readRecord rejects SFI of 0`() { + EmvApduCommands.readRecord(sfi = 0, recordNumber = 1) + } + + @Test + fun `parseAfl extracts entries correctly`() { + // AFL format: SFI<<3 | firstRec | lastRec | odaCount + // SFI 1, records 1-3 + // SFI 2, record 1 only + val afl = byteArrayOf( + 0x08, 0x01, 0x03, 0x00, // SFI 1, records 1-3 + 0x10, 0x01, 0x01, 0x00 // SFI 2, record 1 + ) + + val entries = EmvApduCommands.parseAfl(afl) + + assertThat(entries).hasSize(2) + assertThat(entries[0]).isEqualTo(Triple(1, 1, 3)) + assertThat(entries[1]).isEqualTo(Triple(2, 1, 1)) + } + + @Test + fun `parseAfl handles empty AFL`() { + val entries = EmvApduCommands.parseAfl(byteArrayOf()) + + assertThat(entries).isEmpty() + } + + @Test + fun `parseAfl handles malformed AFL`() { + // Not a multiple of 4 + val entries = EmvApduCommands.parseAfl(byteArrayOf(0x08, 0x01, 0x03)) + + assertThat(entries).isEmpty() + } + + @Test + fun `isSuccess detects 9000 status`() { + val response = byteArrayOf(0x00, 0x00, 0x90.toByte(), 0x00) + + assertThat(EmvApduCommands.isSuccess(response)).isTrue() + } + + @Test + fun `isSuccess rejects non-9000 status`() { + val response6A82 = byteArrayOf(0x00, 0x6A.toByte(), 0x82.toByte()) + val response6985 = byteArrayOf(0x00, 0x69.toByte(), 0x85.toByte()) + + assertThat(EmvApduCommands.isSuccess(response6A82)).isFalse() + assertThat(EmvApduCommands.isSuccess(response6985)).isFalse() + } + + @Test + fun `getResponseData strips status bytes`() { + val response = byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0x90.toByte(), 0x00) + val data = EmvApduCommands.getResponseData(response) + + assertThat(data.size).isEqualTo(2) + assertThat(data[0]).isEqualTo(0xAA.toByte()) + assertThat(data[1]).isEqualTo(0xBB.toByte()) + } + + @Test + fun `getStatusWord extracts status correctly`() { + val response9000 = byteArrayOf(0x00, 0x90.toByte(), 0x00) + val response6A82 = byteArrayOf(0x00, 0x6A.toByte(), 0x82.toByte()) + + assertThat(EmvApduCommands.getStatusWord(response9000)).isEqualTo(0x9000) + assertThat(EmvApduCommands.getStatusWord(response6A82)).isEqualTo(0x6A82) + } + + @Test + fun `Aids constants are correct`() { + assertThat(EmvApduCommands.Aids.VISA.toHexString()).isEqualTo("A0000000031010") + assertThat(EmvApduCommands.Aids.MASTERCARD.toHexString()).isEqualTo("A0000000041010") + assertThat(EmvApduCommands.Aids.AMEX.toHexString()).isEqualTo("A00000002501") + assertThat(EmvApduCommands.Aids.DISCOVER.toHexString()).isEqualTo("A0000001523010") + } +} diff --git a/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/nfcdirect/TlvParserTest.kt b/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/nfcdirect/TlvParserTest.kt new file mode 100644 index 00000000000..684491d8edc --- /dev/null +++ b/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/nfcdirect/TlvParserTest.kt @@ -0,0 +1,188 @@ +package com.stripe.android.common.taptoadd.nfcdirect + +import com.google.common.truth.Truth.assertThat +import com.stripe.android.common.taptoadd.nfcdirect.TlvParser.hexToByteArray +import com.stripe.android.common.taptoadd.nfcdirect.TlvParser.toHexString +import org.junit.Test + +class TlvParserTest { + + @Test + fun `parse single byte tag with single byte length`() { + // Tag 5A (PAN), Length 08, Value 4111111111111111 + val data = "5A084111111111111111".hexToByteArray() + val result = TlvParser.parse(data) + + assertThat(result).containsKey("5A") + assertThat(result["5A"]?.toHexString()).isEqualTo("4111111111111111") + } + + @Test + fun `parse two byte tag`() { + // Tag 5F24 (Expiry), Length 03, Value 261231 (Dec 2026) + val data = "5F2403261231".hexToByteArray() + val result = TlvParser.parse(data) + + assertThat(result).containsKey("5F24") + assertThat(result["5F24"]?.toHexString()).isEqualTo("261231") + } + + @Test + fun `parse multiple TLV elements`() { + // PAN + Expiry + Cardholder Name + val data = "5A084111111111111111" + + "5F2403261231" + + "5F200E4A4F484E20444F452F4D522E20" + val result = TlvParser.parse(data.hexToByteArray()) + + assertThat(result).hasSize(3) + assertThat(result).containsKey("5A") + assertThat(result).containsKey("5F24") + assertThat(result).containsKey("5F20") + } + + @Test + fun `parse constructed tag recursively`() { + // Tag 6F (FCI Template) containing nested tags + // 6F 0F (length 15) + // 84 07 A0000000041010 (DF Name - Mastercard AID) + // A5 04 (FCI Proprietary) + // 50 02 4D43 (App Label "MC") + val data = "6F0F8407A0000000041010A5045002MC".hexToByteArray() + val result = TlvParser.parse(data) + + // Should find both outer and inner tags + assertThat(result).containsKey("6F") + assertThat(result).containsKey("84") + assertThat(result).containsKey("A5") + assertThat(result).containsKey("50") + } + + @Test + fun `parse long form length`() { + // Tag with length > 127 (using long form) + // Tag 70, Length 81 80 (128 bytes), followed by 128 bytes of data + val valueBytes = ByteArray(128) { 0xAA.toByte() } + val data = byteArrayOf(0x70, 0x81.toByte(), 0x80.toByte()) + valueBytes + val result = TlvParser.parse(data) + + assertThat(result).containsKey("70") + assertThat(result["70"]?.size).isEqualTo(128) + } + + @Test + fun `findTag returns correct value`() { + val data = "5A084111111111111111".hexToByteArray() + val pan = TlvParser.findTag(data, "5A") + + assertThat(pan).isNotNull() + assertThat(pan?.toHexString()).isEqualTo("4111111111111111") + } + + @Test + fun `findTag returns null for missing tag`() { + val data = "5A084111111111111111".hexToByteArray() + val missing = TlvParser.findTag(data, "5F24") + + assertThat(missing).isNull() + } + + @Test + fun `findTag is case insensitive`() { + val data = "5a084111111111111111".hexToByteArray() + val pan = TlvParser.findTag(data, "5A") + + assertThat(pan).isNotNull() + } + + @Test + fun `parseToElements returns structured data`() { + val data = "5A084111111111111111".hexToByteArray() + val elements = TlvParser.parseToElements(data) + + assertThat(elements).hasSize(1) + assertThat(elements[0].tag).isEqualTo("5A") + assertThat(elements[0].isConstructed).isFalse() + } + + @Test + fun `parseToElements identifies constructed tags`() { + val data = "6F0F8407A0000000041010A5045002MC".hexToByteArray() + val elements = TlvParser.parseToElements(data) + + assertThat(elements).hasSize(1) + assertThat(elements[0].tag).isEqualTo("6F") + assertThat(elements[0].isConstructed).isTrue() + assertThat(elements[0].children).isNotEmpty() + } + + @Test + fun `hexToByteArray conversion`() { + val hex = "DEADBEEF" + val bytes = hex.hexToByteArray() + + assertThat(bytes.size).isEqualTo(4) + assertThat(bytes[0]).isEqualTo(0xDE.toByte()) + assertThat(bytes[1]).isEqualTo(0xAD.toByte()) + assertThat(bytes[2]).isEqualTo(0xBE.toByte()) + assertThat(bytes[3]).isEqualTo(0xEF.toByte()) + } + + @Test + fun `toHexString conversion`() { + val bytes = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()) + val hex = bytes.toHexString() + + assertThat(hex).isEqualTo("DEADBEEF") + } + + @Test + fun `handles empty input`() { + val result = TlvParser.parse(byteArrayOf()) + + assertThat(result).isEmpty() + } + + @Test + fun `skips padding bytes`() { + // 00 and FF are padding bytes that should be skipped + val data = "005A0841111111111111110000FF".hexToByteArray() + val result = TlvParser.parse(data) + + // Should not have entries for padding + assertThat(result).doesNotContainKey("00") + assertThat(result).doesNotContainKey("FF") + } + + @Test + fun `parse Track 2 equivalent data`() { + // Tag 57 (Track 2), typical format: PAN D Expiry Service Code + val data = "5712411111111111111D2612201123456789".hexToByteArray() + val result = TlvParser.parse(data) + + assertThat(result).containsKey("57") + assertThat(result["57"]?.toHexString()).contains("4111111111111111") + } + + @Test + fun `parse real GPO response format 1`() { + // Format 1 (tag 80): AIP (2 bytes) + AFL + // 80 0E 1900 08010100 10010300 18010100 + val data = "800E190008010100100103001801010".hexToByteArray() + val result = TlvParser.parse(data) + + assertThat(result).containsKey("80") + } + + @Test + fun `parse real GPO response format 2`() { + // Format 2 (tag 77): constructed template with AIP and AFL tags + // 77 0E 82 02 1900 94 08 08010100 10010300 + val data = "770E82021900940808010100100103".hexToByteArray() + val result = TlvParser.parse(data) + + assertThat(result).containsKey("77") + assertThat(result).containsKey("82") // AIP + assertThat(result).containsKey("94") // AFL + } +} diff --git a/stripe-core/src/main/java/com/stripe/android/core/utils/FeatureFlags.kt b/stripe-core/src/main/java/com/stripe/android/core/utils/FeatureFlags.kt index 1fdc13c62cb..f7fe4ec60c6 100644 --- a/stripe-core/src/main/java/com/stripe/android/core/utils/FeatureFlags.kt +++ b/stripe-core/src/main/java/com/stripe/android/core/utils/FeatureFlags.kt @@ -17,6 +17,7 @@ object FeatureFlags { val forceLinkWebAuth = FeatureFlag("Link: Force web auth") val enableAttestationOnIntentConfirmation = FeatureFlag("Enable Attestation on Intent Confirmation") val enableTapToAdd = FeatureFlag("Enable Tap to Add") + val nfcDirect = FeatureFlag("NFC Direct Card Reading") val enableKlarnaFormRemoval = FeatureFlag("Remove forms from Klarna") }