diff --git a/README.md b/README.md
index ee3b7fdc..7ad2db33 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,7 @@
- ♻️ Universal solution (Apple Wallet and Google Wallet)
- 💳 Easy and secure addition of payment cards from your app directly
- 💰 Supports Visa, MasterCard, Amex and Discover payment cards
+- 🔐 UAPP (Unified Android Push Provisioning) support for enhanced security
- 🎉 Expo support (expo-plugin)
## Getting started
@@ -21,7 +22,7 @@ To be able to interact with the Google Wallet on the Android please make sure to
1. Visit [the Google Pay Android Push Provisioning API documentation](https://developers.google.com/pay/issuers/apis/push-provisioning/android/) and request access to it.
2. Once getting an approval from the Google team
- 1. Download the [TapAndPay SDK](https://developers.google.com/pay/issuers/apis/push-provisioning/android/releases).
+ 1. Download the [TapAndPay SDK](https://developers.google.com/pay/issuers/apis/push-provisioning/android/releases). The library uses the UAPP APIs (`PaymentCredentialsGenerator`, `PushTokenizeResult`), so older SDK versions without them will not compile. Tested with 18.10.1.
2. Unzip it and extract the SDK into the `/android/libs` folder in your React Native project (if there is no `libs` folder, create one).
3. Add `/android/libs` to `.gitignore`.
@@ -134,7 +135,7 @@ In your `app.json` file, add the following configuration:
"@expensify/react-native-wallet",
{
enableApplePayProvisioning: true,
- googleTapAndPaySdkPath: "./libs/tapandpay-v18.7.0.zip", // path to the Google Tap & Pay SDK zip file
+ googleTapAndPaySdkPath: "./libs/tapandpay-v18.10.1.zip", // path to the Google Tap & Pay SDK zip file
},
],
]
@@ -157,6 +158,7 @@ Here you can find data elements used in the library, essential to work with Goog
### Android
- **Opaque Payment Card** (OPC) - a binary blob of information Google Pay receives from the issuer app that could be presented to TSP to receive a token.
+- **Google Opaque Payment Card** (Google OPC) - an encrypted version of the OPC required for UAPP (Unified Android Push Provisioning) flow. This is generated by your backend using Google's encryption keys during the provisioning process. [Learn more about UAPP →](./docs/UAPP.md)
- **Token Service Provider** (TSP) - a service that enhances payment security by replacing a credit card number during transactions with a unique digital identifier - token. The TSP specifies the tokenization service used to create a given token e.g. Visa, MasterCard, American Express.
- **Token Reference ID** - a unique identifying number to refer to a DPAN (Dynamic Personal Account Number). Token Providers will assign each DPAN an issuer token ID at the time of tokenization.
@@ -168,7 +170,7 @@ Here you can find data elements used in the library, essential to work with Goog
- **Ephemeral Public Key** - a key used by elliptic curve cryptography (ECC) (Base 64 encoded).
# API Reference
-The library offers seven functions for seamless integration and use of the Apple Wallet and Google Wallet APIs. Additionally, it includes one listener that informs when the added card has been activated. Below, these functions are described along with the data types involved.
+The library offers functions for seamless integration and use of the Apple Wallet and Google Wallet APIs. Additionally, it includes listeners and handlers for managing provisioning events. Below, these functions are described along with the data types involved.
## Functions
@@ -178,26 +180,49 @@ The library offers seven functions for seamless integration and use of the Apple
| **checkWalletAvailability** | Checks if the wallet is ready and initializes it if possible. | None | `boolean` | ✅ | ✅ |
| **getCardStatusBySuffix** | Retrieves the current status of a card in the wallet. | `lastDigits: string`
(The last few digits of the card number) | `CardStatus` | ✅ | ✅ |
| **getCardStatusByIdentifier** | Returns the state of a card based on a platform-specific identifier. On Android, it's `Token Reference ID` and on iOS, it's `Primary Account Identifier`. | `identifier: string`,
`tsp: string` | `CardStatus` | ✅ | ✅ |
-| **addCardToGoogleWallet** | Initiates native Push Provisioning flow for adding a card to the Google Wallet. | `data`: `AndroidCardData` | `TokenizationStatus` | ❌ | ✅ |
+| **hasEligibleTokenizationTarget** | Checks whether the card can still be added to a Google Wallet target (the phone or a paired wearable). Google recommends it for the "Add to Google Wallet" button when wearables are supported, because a card already on the phone can still be added to a watch. | `last4Digits: string`,
`network: string`,
`issuerName: string` | `boolean` | ❌ | ✅ |
+| **addCardToGoogleWallet** | Initiates native Push Provisioning flow for adding a card to the Google Wallet. Requires a handler that returns the payment credentials for the UAPP flow. [Learn more →](./docs/UAPP.md) | `data`: `AndroidCardData`,
`handlePaymentCredentials`: `(data: onPaymentCredentialsRequestPayload) => Promise` | `TokenizationStatus` | ❌ | ✅ |
| **resumeAddCardToGoogleWallet** | Resumes the Push Provisioning flow for adding a card to the Google Wallet using existing token reference ID. | `data`: `AndroidResumeCardData` | `TokenizationStatus` | ❌ | ✅ |
| **listTokens** | Lists all tokens currently stored in the Google Wallet. | None | `TokenInfo[]` | ❌ | ✅ |
| **addCardToAppleWallet** | Initiates native Push Provisioning flow for adding a card to the Apple Wallet. | `data`: `IOSCardData`,
`issuerEncrypt-`
`PayloadCallback: IOSIssuerCallback` | `void` | ✅ | ❌ |
-
## Data Types
| Type | Description | Fields |
|------|-------------|--------|
| **AndroidWalletData** | Specific information for Android devices required for wallet transactions. | `deviceID: string`,
`walletAccountID: string` |
-| **AndroidCardData** | Data related to a card that is to be added on Android platform wallets. | `network: string`,
`opaquePaymentCard: string`,
`cardHolderName: string`,
`lastDigits: string`,
`userAddress: UserAddress` |
+| **AndroidCardData** | Data related to a card that is to be added on Android platform wallets. | `network: string`,
`opaquePaymentCard: string`,
`cardHolderName: string`,
`lastDigits: string`,
`userAddress: UserAddress`,
`isVirtualCard?: boolean`,
`isBounceProvisioned?: boolean` |
| **AndroidResumeCardData** | Simplified data structure for resuming card addition to Google Wallet using existing token reference ID. | `network: string`,
`tokenReferenceID: string`,
`cardHolderName?: string`,
`lastDigits?: string` |
+| **AndroidPaymentCredentialsResponse** | Response object containing encrypted payment credentials for UAPP flow. [Learn more →](./docs/UAPP.md) | `opaquePaymentCard: string`,
`googleOpaquePaymentCard: string` |
+| **onPaymentCredentialsRequestPayload** | Payload received when Google Wallet requests payment credentials during UAPP provisioning. [Learn more →](./docs/UAPP.md) | `requestId: string`,
`serverSessionId: string`,
`walletId: string`,
`tokenRequestorId: string \| null`,
`opaquePaymentCard: string` |
| **UserAddress** | Structured address used for cardholder verification. | `name: string`,
`addressOne: string`,
`addressTwo: string`,
`city: string`,
`administrativeArea: string`,
`countryCode: string`,
`postalCode: string`,
`phoneNumber: string` |
| **IOSCardData** | Data related to a card that is to be added on iOS platform. | `network: string`,
`activationData: string`,
`encryptedPassData: string`,
`ephemeralPublicKey: string`,
`cardHolderTitle: string`,
`cardHolderName: string`,
`lastDigits: string`,
`cardDescription: string`,
`cardDescriptionComment: string` |
-| **onCardActivatedPayload** | Data used by listener to notice when a card's status changes. | `tokenId: string`,
`status: 'activated' \| 'canceled'`
|
+| **onCardActivatedPayload** | Data used by listener to notice when a card's status changes. | `tokenId?: string \| null`,
`status: 'activated' \| 'canceled'`
|
| **IOSIssuerCallback** | This callback is invoked with a nonce, its signature, and a certificate array obtained from Apple. It is expected that you will forward these details to your server or the card issuer's API to securely encrypt the payload required for adding cards to the Apple Wallet. | `(nonce: string, nonceSignature: string, certificate: string[]) => IOSEncryptPayload` |
| **IOSEncryptPayload** | An object containing the necessary elements to complete the addition of a card to Apple Wallet. | `encryptedPassData: string`,
`activationData: string`,
`ephemeralPublicKey: string` |
| **TokenInfo** | Information about a token stored in Google Wallet. | `identifier: string`,
`lastDigits: string`,
`tokenState: number` |
+### AndroidCardData Optional Fields
+
+The `AndroidCardData` type includes two optional boolean fields that provide additional information about the card being provisioned:
+
+#### `isVirtualCard?: boolean`
+
+Indicates whether the card being provisioned is a virtual card. Virtual cards are digitally-generated card numbers that are linked to a physical card account. They provide enhanced security by:
+- Allowing users to make online purchases without exposing their physical card number
+- Enabling easy cancellation and regeneration without affecting the physical card
+- Providing transaction-specific or merchant-specific card numbers
+
+When set to `true`, this flag informs Google Wallet that the card being provisioned is a virtual card, which may affect how the card is displayed and managed in the wallet interface.
+
+#### `isBounceProvisioned?: boolean`
+
+Indicates whether the card has been provisioned through "bounce provisioning". Bounce provisioning is a two-step provisioning flow where:
+1. The user initially provisions a card directly through Google Wallet (outside your app)
+2. Your app later "bounces" or redirects the user back to complete additional verification or setup steps
+
+This flag helps Google Wallet track cards that were initially added through the Google Wallet app but require additional app-specific configuration or verification. When set to `true`, it signals that this card was previously provisioned through the Google Wallet interface and is now being completed or updated through your application.
+
## Card Status
| Type | Possible Values |
@@ -283,17 +308,44 @@ const base64ToHex = (base64: string): string => Buffer.from(base64, "base64").to
This will depend on the implementation of your backend/issuer so make sure to check with them otherwise IOS might fail to provision the card.
+# UAPP (Unified Android Push Provisioning)
+
+For Android applications that require enhanced security for card provisioning, this library supports UAPP (Unified Android Push Provisioning) flow with Google Wallet.
+
+📖 **[Read the complete UAPP documentation →](./docs/UAPP.md)**
+
+The UAPP guide covers:
+- What UAPP is and how it works
+- Step-by-step setup instructions
+- Backend implementation requirements
+- Error handling and troubleshooting
+- Complete code examples
+
# Publishing your app
To successfully publish your app, you will need to navigate through a series of mandatory test cases on both platforms.
### Android
+> [!WARNING]
+> **Testing is mandatory before production release.** Your access to the Google Wallet production environment may be revoked if you do not provide the required test evidence for launch approval. Complete all testing requirements before deploying your app.
+
Before deploying your app to the Google Play Store, make sure you have taken care of the security when implementing Push Provisioning and properly tested your code. To accomplish it, please familiarize yourself with [the Google's App Push Provisioning best practices](https://developers.google.com/pay/issuers/apis/push-provisioning/android/best-practices).
The latest information about deploying apps with Google TapAndPay SDK can be found in the [pre-launch process](https://developers.google.com/pay/issuers/apis/push-provisioning/android/launch-process#step_3_issuer_app_product_review) and [beta tests](https://developers.google.com/pay/issuers/apis/push-provisioning/android/beta-testing) sections in Google documentation. Make sure to complete all of the steps specified by Google connected to the __Google's branding__, __API safety__, and __app stability__.
-The app will need to be reviewed by Google. During this process, it will need to pass 4 mandatory test cases that are specified [here](https://developers.google.com/pay/issuers/apis/push-provisioning/android/test-cases). They verify how your app handles card state tracking in different scenarios.
+#### Testing Requirements
+
+The app will need to be reviewed by Google. During this process, it will need to pass 4 mandatory test cases that are specified in the [official Google testing documentation](https://developers.google.com/pay/issuers/apis/push-provisioning/android/test-cases). They verify how your app handles card state tracking in different scenarios.
+
+**Key testing criteria include:**
+- Proper handling of card states (active, pending, suspended, etc.)
+- Correct UI behavior when cards are already provisioned
+- Secure handling of sensitive payment data
+- Compliance with Google's branding guidelines
+- Proper error handling and user feedback
+
+Please refer to the [complete testing criteria documentation](https://developers.google.com/pay/issuers/apis/push-provisioning/android/test-cases) before submitting your app for review.
> [!NOTE]
>Please make sure to hide the `Add to Google Wallet` buttons when cards are already added to the wallet.
diff --git a/android/src/main/java/com/expensify/wallet/Utils.kt b/android/src/main/java/com/expensify/wallet/Utils.kt
index da8f0a30..7bce2507 100644
--- a/android/src/main/java/com/expensify/wallet/Utils.kt
+++ b/android/src/main/java/com/expensify/wallet/Utils.kt
@@ -29,12 +29,17 @@ object Utils {
.setPhoneNumber(addressMap.getString("phoneNumber") ?: "")
.build()
+ val isVirtual = if (hasKey("isVirtualCard")) this.getBoolean("isVirtualCard") else false
+ val isBounce = if (hasKey("isBounceProvisioned")) this.getBoolean("isBounceProvisioned") else false
+
return CardData(
network = this.getString("network") ?: "",
opaquePaymentCard = this.getString("opaquePaymentCard") ?: "",
cardHolderName = this.getString("cardHolderName") ?: "",
lastDigits = this.getString("lastDigits") ?: "",
- userAddress = userAddress
+ userAddress = userAddress,
+ isVirtualCard = isVirtual,
+ isBounceProvisioned = isBounce,
)
}
diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt
index dfa1a398..a77dee0a 100644
--- a/android/src/main/java/com/expensify/wallet/WalletModule.kt
+++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt
@@ -5,6 +5,15 @@ import android.app.Activity.RESULT_CANCELED
import android.app.Activity.RESULT_OK
import android.content.ActivityNotFoundException
import android.content.Intent
+import androidx.core.content.IntentCompat
+import com.expensify.wallet.Utils.getAsyncResult
+import com.expensify.wallet.Utils.toCardData
+import com.expensify.wallet.error.InvalidNetworkError
+import com.expensify.wallet.event.OnCardActivatedEvent
+import com.expensify.wallet.model.CardData
+import com.expensify.wallet.model.CardStatus
+import com.expensify.wallet.model.TokenizationStatus
+import com.expensify.wallet.model.WalletData
import com.facebook.react.bridge.ActivityEventListener
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
@@ -15,25 +24,35 @@ import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule
+import com.google.android.gms.common.api.ApiException
import com.google.android.gms.tapandpay.TapAndPay
import com.google.android.gms.tapandpay.TapAndPayClient
+import com.google.android.gms.tapandpay.issuer.GeneratePaymentCredentialsException
+import com.google.android.gms.tapandpay.issuer.GeneratePaymentCredentialsRequest
+import com.google.android.gms.tapandpay.issuer.GeneratePaymentCredentialsResponse
+import com.google.android.gms.tapandpay.issuer.GetTokenStatusRequest
+import com.google.android.gms.tapandpay.issuer.HasEligibleTokenizationTargetRequest
+import com.google.android.gms.tapandpay.issuer.ListTokensRequest
+import com.google.android.gms.tapandpay.issuer.PaymentCredentialsGenerator
+import com.google.android.gms.tapandpay.issuer.PushTokenizeExtraOptions
import com.google.android.gms.tapandpay.issuer.PushTokenizeRequest
+import com.google.android.gms.tapandpay.issuer.PushTokenizeResult
+import com.google.android.gms.tapandpay.issuer.TokenizeRequest
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
-import com.expensify.wallet.Utils.getAsyncResult
-import com.expensify.wallet.Utils.toCardData
-import com.expensify.wallet.error.InvalidNetworkError
-import com.expensify.wallet.event.OnCardActivatedEvent
-import com.expensify.wallet.model.CardStatus
-import com.expensify.wallet.model.TokenizationStatus
-import com.expensify.wallet.model.WalletData
-import com.google.android.gms.common.api.ApiException
-import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.withTimeoutOrNull
import java.nio.charset.Charset
import java.util.Locale
+import java.util.UUID
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.Future
class WalletModule internal constructor(context: ReactApplicationContext) :
@@ -41,11 +60,14 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
companion object {
const val NAME = "RNWallet"
const val REQUEST_CODE_PUSH_TOKENIZE: Int = 0xA001
+ const val REQUEST_CODE_RESUME_TOKENIZE: Int = 0xA003
const val REQUEST_CREATE_WALLET: Int = 0xA002
const val E_SDK_API = "SDK API Error"
const val E_OPERATION_FAILED = "E_OPERATION_FAILED"
const val E_INVALID_DATA = "E_INVALID_DATA"
+
+ const val PAYMENT_CREDENTIALS_TIMEOUT_MS = 60_000L
}
private val activity: Activity
@@ -57,6 +79,8 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
private var pendingPushTokenizePromise: Promise? = null
private val moduleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ private val pendingPaymentCredentials = ConcurrentHashMap>()
+
override fun initialize() {
super.initialize()
reactApplicationContext.addActivityEventListener(cardListener)
@@ -66,42 +90,100 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
super.invalidate()
moduleScope.cancel()
reactApplicationContext.removeActivityEventListener(cardListener)
+ pendingPaymentCredentials.clear()
}
private val cardListener = object : ActivityEventListener {
override fun onActivityResult(
activity: Activity, requestCode: Int, resultCode: Int, data: Intent?
) {
- if (requestCode == REQUEST_CREATE_WALLET) {
- pendingCreateWalletPromise?.resolve(resultCode == RESULT_OK)
- pendingCreateWalletPromise = null
- } else if (requestCode == REQUEST_CODE_PUSH_TOKENIZE) {
- val localPromise = pendingPushTokenizePromise
- pendingPushTokenizePromise = null
- if (resultCode == RESULT_OK) {
- val tokenId = data?.getStringExtra(TapAndPay.EXTRA_ISSUER_TOKEN_ID)
- sendEvent(
- context,
- OnCardActivatedEvent.NAME,
- OnCardActivatedEvent("activated", tokenId).toMap()
- )
- localPromise?.resolve(TokenizationStatus.SUCCESS.code)
- } else if (resultCode == RESULT_CANCELED) {
- sendEvent(
- context,
- OnCardActivatedEvent.NAME,
- OnCardActivatedEvent("canceled", null).toMap()
- )
- localPromise?.resolve(TokenizationStatus.CANCELED.code)
- } else {
- localPromise?.resolve(TokenizationStatus.ERROR.code)
+ when (requestCode) {
+ REQUEST_CREATE_WALLET -> {
+ pendingCreateWalletPromise?.resolve(resultCode == RESULT_OK)
+ pendingCreateWalletPromise = null
}
+ REQUEST_CODE_RESUME_TOKENIZE -> handleResumeTokenizeResult(resultCode, data)
+ REQUEST_CODE_PUSH_TOKENIZE -> handlePushTokenizeResult(resultCode, data)
}
}
override fun onNewIntent(intent: Intent) {}
}
+ private fun takePendingTokenizePromise(): Promise? =
+ pendingPushTokenizePromise.also { pendingPushTokenizePromise = null }
+
+ private fun sendCardActivatedEvent(status: String, tokenId: String?) {
+ sendEvent(reactApplicationContext, OnCardActivatedEvent.NAME, OnCardActivatedEvent(status, tokenId).toMap())
+ }
+
+ private fun handleResumeTokenizeResult(resultCode: Int, data: Intent?) {
+ val promise = takePendingTokenizePromise()
+ when (resultCode) {
+ RESULT_OK -> {
+ sendCardActivatedEvent("activated", data?.getStringExtra(TapAndPay.EXTRA_ISSUER_TOKEN_ID))
+ promise?.resolve(TokenizationStatus.SUCCESS.code)
+ }
+ RESULT_CANCELED -> {
+ sendCardActivatedEvent("canceled", null)
+ promise?.resolve(TokenizationStatus.CANCELED.code)
+ }
+ else -> promise?.resolve(TokenizationStatus.ERROR.code)
+ }
+ }
+
+ private fun handlePushTokenizeResult(resultCode: Int, data: Intent?) {
+ val promise = takePendingTokenizePromise()
+ if (resultCode == RESULT_CANCELED) {
+ sendCardActivatedEvent("canceled", null)
+ promise?.resolve(TokenizationStatus.CANCELED.code)
+ return
+ }
+
+ val result = data?.let {
+ IntentCompat.getParcelableExtra(it, TapAndPay.EXTRA_PUSH_TOKENIZE_RESULT, PushTokenizeResult::class.java)
+ }
+
+ if (result == null) {
+ handlePushTokenizeResultExtras(resultCode, data, promise)
+ return
+ }
+
+ val successfulOutcome = result.tokenizationOutcomes.firstOrNull { it.tokenResult }
+ if (result.cardResult || successfulOutcome != null) {
+ sendCardActivatedEvent("activated", successfulOutcome?.issuerTokenId)
+ promise?.resolve(TokenizationStatus.SUCCESS.code)
+ } else {
+ promise?.reject(E_OPERATION_FAILED, "Card not saved. Status: ${result.cardStatus}")
+ }
+ }
+
+ // Google documents the result both as PushTokenizeResult and as separate extras, so read the extras when the object is missing
+ private fun handlePushTokenizeResultExtras(resultCode: Int, data: Intent?, promise: Promise?) {
+ val hasTokenResult = data?.hasExtra(TapAndPay.EXTRA_TOKEN_RESULT) == true
+ val hasCardResult = data?.hasExtra(TapAndPay.EXTRA_CARD_RESULT) == true
+
+ if (!hasTokenResult && !hasCardResult) {
+ if (resultCode == RESULT_OK) {
+ sendCardActivatedEvent("activated", data?.getStringExtra(TapAndPay.EXTRA_ISSUER_TOKEN_ID))
+ promise?.resolve(TokenizationStatus.SUCCESS.code)
+ } else {
+ promise?.resolve(TokenizationStatus.ERROR.code)
+ }
+ return
+ }
+
+ val tokenResult = data?.getBooleanExtra(TapAndPay.EXTRA_TOKEN_RESULT, false) == true
+ val cardResult = data?.getBooleanExtra(TapAndPay.EXTRA_CARD_RESULT, false) == true
+ if (tokenResult || cardResult) {
+ val tokenId = if (tokenResult) data?.getStringExtra(TapAndPay.EXTRA_ISSUER_TOKEN_ID) else null
+ sendCardActivatedEvent("activated", tokenId)
+ promise?.resolve(TokenizationStatus.SUCCESS.code)
+ } else {
+ promise?.reject(E_OPERATION_FAILED, "Card not saved")
+ }
+ }
+
@ReactMethod
override fun ensureGoogleWalletInitialized(promise: Promise) {
val localPromise = PromiseImpl({ _ ->
@@ -147,7 +229,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
@ReactMethod
override fun getCardStatusBySuffix(last4Digits: String, promise: Promise) {
- tapAndPayClient.listTokens()
+ tapAndPayClient.listTokens(ListTokensRequest.Builder().build())
.addOnCompleteListener { task ->
if (!task.isSuccessful || task.result == null) {
promise.resolve(CardStatus.NOT_FOUND_IN_WALLET.code)
@@ -166,7 +248,12 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
@ReactMethod
override fun getCardStatusByIdentifier(identifier: String, tsp: String, promise: Promise) {
- tapAndPayClient.getTokenStatus(getTokenServiceProvider(tsp), identifier)
+ tapAndPayClient.getTokenStatus(
+ GetTokenStatusRequest.Builder()
+ .setTokenServiceProvider(getTokenServiceProvider(tsp))
+ .setIssuerTokenId(identifier)
+ .build()
+ )
.addOnCompleteListener { task ->
if (!task.isSuccessful || task.result == null) {
promise.resolve(CardStatus.NOT_FOUND_IN_WALLET.code)
@@ -183,6 +270,25 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
}
}
+ @ReactMethod
+ override fun hasEligibleTokenizationTarget(last4Digits: String, network: String, issuerName: String, promise: Promise) {
+ try {
+ val request = HasEligibleTokenizationTargetRequest.Builder()
+ .setIdentifier(last4Digits)
+ .setNetwork(getCardNetwork(network))
+ .setTokenServiceProvider(getTokenServiceProvider(network))
+ .setIssuerName(issuerName)
+ .build()
+ tapAndPayClient.hasEligibleTokenizationTarget(request)
+ .addOnSuccessListener { promise.resolve(it) }
+ .addOnFailureListener { e ->
+ promise.reject(E_OPERATION_FAILED, "hasEligibleTokenizationTarget: ${e.localizedMessage}")
+ }
+ } catch (e: InvalidNetworkError) {
+ promise.reject(E_INVALID_DATA, e.message)
+ }
+ }
+
@ReactMethod
override fun addCardToGoogleWallet(
data: ReadableMap, promise: Promise
@@ -197,6 +303,11 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
val displayName = getDisplayName(data, cardData.network)
pendingPushTokenizePromise = promise
+ val pushTokenizeExtraOptions = PushTokenizeExtraOptions.newBuilder()
+ .setIsBounceProvisioned(cardData.isBounceProvisioned)
+ .setEnrollForVirtualCards(cardData.isVirtualCard)
+ .build()
+
val pushTokenizeRequest = PushTokenizeRequest.Builder()
.setOpaquePaymentCard(cardData.opaquePaymentCard.toByteArray(Charset.forName("UTF-8")))
.setNetwork(cardNetwork)
@@ -204,17 +315,130 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
.setDisplayName(displayName)
.setLastDigits(cardData.lastDigits)
.setUserAddress(cardData.userAddress)
+ .setPaymentCredentialsGenerator(createPaymentCredentialsGenerator(cardData))
+ .setPushTokenizeExtraOptions(pushTokenizeExtraOptions)
.build()
- tapAndPayClient.pushTokenize(
- activity, pushTokenizeRequest, REQUEST_CODE_PUSH_TOKENIZE
- )
+ tapAndPayClient.pushTokenize(pushTokenizeRequest)
+ .addOnSuccessListener { pendingIntent ->
+ try {
+ activity.startIntentSenderForResult(
+ pendingIntent.intentSender,
+ REQUEST_CODE_PUSH_TOKENIZE,
+ null, 0, 0, 0
+ )
+ } catch (e: Exception) {
+ pendingPushTokenizePromise = null
+ promise.reject(E_OPERATION_FAILED, "Failed to launch Google Pay: ${e.message}")
+ }
+ }
+ .addOnFailureListener { e ->
+ pendingPushTokenizePromise = null
+ promise.reject(E_OPERATION_FAILED, "Google Pay API Error: ${e.message}")
+ }
} catch (e: java.lang.Exception) {
pendingPushTokenizePromise = null
promise.reject(e)
}
}
+ // Google Wallet only understands GeneratePaymentCredentialsException; UNKNOWN_ERROR covers backend failures and timeouts
+ private fun paymentCredentialsError() =
+ GeneratePaymentCredentialsException(GeneratePaymentCredentialsException.UNKNOWN_ERROR)
+
+ private fun createPaymentCredentialsGenerator(
+ cardData: CardData
+ ): PaymentCredentialsGenerator {
+ return object : PaymentCredentialsGenerator {
+ override fun generate(request: GeneratePaymentCredentialsRequest): Future {
+ if (!request.googleOpaquePaymentCardRequested) {
+ val response = GeneratePaymentCredentialsResponse.Builder()
+ .setOpaquePaymentCard(cardData.opaquePaymentCard.toByteArray(Charsets.UTF_8))
+ .build()
+
+ return CompletableFuture.completedFuture(response)
+ }
+
+ val future = CompletableFuture()
+ val credentials = CompletableDeferred()
+ val requestId = UUID.randomUUID().toString()
+ pendingPaymentCredentials[requestId] = credentials
+ moduleScope.launch(start = CoroutineStart.UNDISPATCHED) {
+ // null on timeout, on reject, or when invalidate() cancels the scope
+ val response = runCatching {
+ withTimeoutOrNull(PAYMENT_CREDENTIALS_TIMEOUT_MS) { credentials.await() }
+ }.getOrNull()
+ pendingPaymentCredentials.remove(requestId)
+ if (response != null) {
+ future.complete(response)
+ } else {
+ future.completeExceptionally(paymentCredentialsError())
+ }
+ }
+
+ val params = Arguments.createMap().apply {
+ putString("requestId", requestId)
+ putString("serverSessionId", request.serverSessionId)
+ putString("walletId", request.walletId)
+ putString("tokenRequestorId", request.tokenRequestorId)
+ putString("opaquePaymentCard", cardData.opaquePaymentCard)
+ }
+
+ // send event to the RN so the Google OPC can be generated on the backend
+ sendEvent(reactApplicationContext, "onPaymentCredentialsRequest", params)
+ return future
+ }
+
+ // This switch enables the UAPP flow
+ override fun getGoogleOpaquePaymentCardSupported(): Boolean {
+ return true
+ }
+ }
+ }
+
+ @ReactMethod
+ override fun AndroidProvidePaymentCredentials(requestId: String, responseData: ReadableMap, promise: Promise) {
+ val credentials = pendingPaymentCredentials.remove(requestId)
+ if (credentials == null) {
+ promise.reject(E_OPERATION_FAILED, "Request ID not found or timed out")
+ return
+ }
+
+ try {
+ val tspOpc = responseData.getString("opaquePaymentCard")
+ val googleOpc = responseData.getString("googleOpaquePaymentCard")
+
+ if (tspOpc == null) {
+ throw Exception("opaquePaymentCard is required")
+ }
+ if (googleOpc == null) {
+ throw Exception("googleOpaquePaymentCard is required")
+ }
+ val response = GeneratePaymentCredentialsResponse.Builder()
+ .setGoogleOpaquePaymentCard(googleOpc.toByteArray(Charsets.UTF_8))
+ .setOpaquePaymentCard(tspOpc.toByteArray(Charsets.UTF_8))
+ .build()
+
+ credentials.complete(response)
+ promise.resolve(true)
+
+ } catch (e: Exception) {
+ credentials.completeExceptionally(paymentCredentialsError())
+ promise.reject(E_OPERATION_FAILED, e.message)
+ }
+ }
+
+ @ReactMethod
+ override fun AndroidRejectPaymentCredentials(requestId: String, errorMessage: String, promise: Promise) {
+ val credentials = pendingPaymentCredentials.remove(requestId)
+ if (credentials == null) {
+ promise.reject(E_OPERATION_FAILED, "Request ID not found or timed out")
+ return
+ }
+ credentials.completeExceptionally(paymentCredentialsError())
+ promise.resolve(true)
+ }
+
@ReactMethod
override fun resumeAddCardToGoogleWallet(data: ReadableMap, promise: Promise) {
if (pendingPushTokenizePromise != null) {
@@ -232,14 +456,13 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
val displayName = getDisplayName(data, network)
pendingPushTokenizePromise = promise
- tapAndPayClient.tokenize(
- activity,
- tokenReferenceID,
- tokenServiceProvider,
- displayName,
- cardNetwork,
- REQUEST_CODE_PUSH_TOKENIZE
- )
+ val tokenizeRequest = TokenizeRequest.Builder()
+ .setIssuerTokenId(tokenReferenceID)
+ .setTokenServiceProvider(tokenServiceProvider)
+ .setCardDisplayName(displayName)
+ .setCardNetwork(cardNetwork)
+ .build()
+ tapAndPayClient.tokenize(activity, tokenizeRequest, REQUEST_CODE_RESUME_TOKENIZE)
} catch (e: java.lang.Exception) {
pendingPushTokenizePromise = null
promise.reject(e)
@@ -248,13 +471,13 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
@ReactMethod
override fun listTokens(promise: Promise) {
- tapAndPayClient.listTokens()
+ tapAndPayClient.listTokens(ListTokensRequest.Builder().build())
.addOnCompleteListener { task ->
if (!task.isSuccessful || task.result == null) {
promise.resolve(Arguments.createArray())
return@addOnCompleteListener
}
-
+
val tokensArray = Arguments.createArray()
task.result.forEach { tokenInfo ->
val tokenData = Arguments.createMap().apply {
@@ -264,7 +487,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
}
tokensArray.pushMap(tokenData)
}
-
+
promise.resolve(tokensArray)
}
.addOnFailureListener { e ->
@@ -341,13 +564,13 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
data.getString("cardHolderName")?.let { name ->
if (name.isNotEmpty()) return name
}
-
+
data.getString("lastDigits")?.let { digits ->
if (digits.isNotEmpty()) {
return "${network.uppercase(Locale.getDefault())} Card *$digits"
}
}
-
+
return "${network.uppercase(Locale.getDefault())} Card"
}
diff --git a/android/src/main/java/com/expensify/wallet/model/CardData.kt b/android/src/main/java/com/expensify/wallet/model/CardData.kt
index d9191e0e..a348ee77 100644
--- a/android/src/main/java/com/expensify/wallet/model/CardData.kt
+++ b/android/src/main/java/com/expensify/wallet/model/CardData.kt
@@ -5,8 +5,10 @@ import com.google.android.gms.tapandpay.issuer.UserAddress
data class CardData(
val platform: String = "android",
val network: String,
- val opaquePaymentCard: String,
+ val opaquePaymentCard: String, // TSP OPC
val cardHolderName: String,
val lastDigits: String,
val userAddress: UserAddress,
+ val isVirtualCard: Boolean = false,
+ val isBounceProvisioned: Boolean = false,
)
diff --git a/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java b/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java
index 999f0cb8..052fb38b 100644
--- a/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java
+++ b/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java
@@ -57,6 +57,14 @@ public NativeWalletSpec(ReactApplicationContext reactContext) {
@DoNotStrip
public abstract void addCardToGoogleWallet(ReadableMap cardData, Promise promise);
+ @ReactMethod
+ @DoNotStrip
+ public abstract void AndroidProvidePaymentCredentials(String requestId, ReadableMap responseData, Promise promise);
+
+ @ReactMethod
+ @DoNotStrip
+ public abstract void AndroidRejectPaymentCredentials(String requestId, String errorMessage, Promise promise);
+
@ReactMethod
@DoNotStrip
public abstract void resumeAddCardToGoogleWallet(ReadableMap cardData, Promise promise);
diff --git a/apps/common-app/src/CONST.ts b/apps/common-app/src/CONST.ts
index 8752ef19..e3130925 100644
--- a/apps/common-app/src/CONST.ts
+++ b/apps/common-app/src/CONST.ts
@@ -17,6 +17,8 @@ const AndroidDummyCardData: AndroidCardData = {
cardHolderName: 'John Doe',
lastDigits: '4321',
userAddress: dummyAddress,
+ isVirtualCard: false,
+ isBounceProvisioned: false,
};
const AndroidDummyResumeCardData: AndroidResumeCardData = {
@@ -40,4 +42,9 @@ const IOSDummyEncryptPayload: IOSEncryptPayload = {
ephemeralPublicKey: 'ZXBoZW1lcmFsUHVibGljS2V5MTIz',
};
-export {AndroidDummyCardData, AndroidDummyResumeCardData, IOSDummyCardData, IOSDummyEncryptPayload};
+const AndroidDummyPaymentCredentialsResponse = {
+ opaquePaymentCard: 'encryptedCardInformation123456',
+ googleOpaquePaymentCard: 'GOPC',
+};
+
+export {AndroidDummyCardData, AndroidDummyResumeCardData, IOSDummyCardData, IOSDummyEncryptPayload, AndroidDummyPaymentCredentialsResponse};
diff --git a/apps/common-app/src/walletUtils.ts b/apps/common-app/src/walletUtils.ts
index 2d5769bf..8498306e 100644
--- a/apps/common-app/src/walletUtils.ts
+++ b/apps/common-app/src/walletUtils.ts
@@ -1,5 +1,5 @@
import {addCardToAppleWallet, addCardToGoogleWallet, listTokens, resumeAddCardToGoogleWallet} from '@expensify/react-native-wallet';
-import type {CardStatus} from '@expensify/react-native-wallet';
+import type {AndroidPaymentCredentialsResponse, CardStatus, onPaymentCredentialsRequestPayload} from '@expensify/react-native-wallet';
import {Platform} from 'react-native';
import * as CONST from './CONST';
@@ -9,6 +9,12 @@ function issuerEncryptPayloadCallback(_nonce: string, _nonceSignature: string, _
return Promise.resolve(CONST.IOSDummyEncryptPayload);
}
+function handlePaymentCredentialsGeneration(_data: onPaymentCredentialsRequestPayload): Promise {
+ // Here send data to your server or you TSP to generate the payment credentials
+ // for example: fetch('https://tsp.com/generatePaymentCredentials', {method: 'POST', body: data})
+ return Promise.resolve(CONST.AndroidDummyPaymentCredentialsResponse);
+}
+
async function addCardToWallet(cardStatus?: CardStatus) {
if (Platform.OS === 'android') {
if (cardStatus === 'requireActivation') {
@@ -24,7 +30,7 @@ async function addCardToWallet(cardStatus?: CardStatus) {
tokenReferenceID: existingToken.identifier,
});
}
- return addCardToGoogleWallet(CONST.AndroidDummyCardData);
+ return addCardToGoogleWallet(CONST.AndroidDummyCardData, handlePaymentCredentialsGeneration);
} else {
return addCardToAppleWallet(CONST.IOSDummyCardData, issuerEncryptPayloadCallback);
}
diff --git a/docs/UAPP.md b/docs/UAPP.md
new file mode 100644
index 00000000..ffb22dad
--- /dev/null
+++ b/docs/UAPP.md
@@ -0,0 +1,177 @@
+# UAPP (Unified Android Push Provisioning) for Android
+
+## What is UAPP?
+
+UAPP (Unified Android Push Provisioning) is an enhanced security flow for Google Wallet that requires encrypted payment credentials during card provisioning. Instead of providing only the TSP Opaque Payment Card (OPC), your backend must also generate a Google Opaque Payment Card (Google OPC) using Google's encryption keys. It allows users to provision cards to Google Wallet and save their cards to their Google Account.
+
+This library handles the UAPP flow automatically. You just need to set up a handler that communicates with your backend to retrieve the encrypted credentials.
+
+> [!IMPORTANT]
+> Please familiarize yourself with [official Google docs](https://developers.google.com/pay/issuers/apis/push-provisioning/android) before starting the implementation!
+
+## How UAPP Works
+
+1. User initiates card provisioning by calling `addCardToGoogleWallet()`
+2. During provisioning, Google Wallet requests encrypted credentials
+3. The library automatically triggers your registered handler with provisioning details
+4. Your handler calls your backend with the server session ID and OPC
+5. Your backend generates the Google OPC using Google's encryption keys
+6. Your handler returns both the TSP OPC and Google OPC
+7. The library automatically provides these credentials to Google Wallet
+8. Provisioning continues seamlessly
+
+## Setup
+
+UAPP is always enabled on Android, so `addCardToGoogleWallet()` requires a payment credentials handler as its second argument:
+
+```typescript
+import { addCardToGoogleWallet } from '@expensify/react-native-wallet';
+
+// Define your handler function
+const handlePaymentCredentials = async (data) => {
+ const { requestId, serverSessionId, walletId, opaquePaymentCard } = data;
+
+ // Call your backend to get encrypted credentials
+ const response = await fetch('https://your-backend.com/api/wallet/credentials', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ serverSessionId,
+ opaquePaymentCard,
+ walletId,
+ }),
+ });
+
+ const credentials = await response.json();
+
+ // Return the credentials - library will automatically send them to Google Wallet
+ return {
+ opaquePaymentCard: credentials.tspOpc,
+ googleOpaquePaymentCard: credentials.googleOpc,
+ };
+};
+
+// Pass the handler when adding a card
+const status = await addCardToGoogleWallet(cardData, handlePaymentCredentials);
+```
+
+## Backend Implementation
+
+> [!NOTE]
+> Please refer to the [official Google documentation](https://developers.google.com/pay/issuers/apis/push-provisioning/android/upgrade_to_upp#generate_the_google_opc_from_the_issuers_backend) while implementing the backend for UAPP Push Provisioning.
+
+Your backend needs to:
+
+1. Receive the `serverSessionId`, `opaquePaymentCard`, `walletId`, and `tokenRequestorId`
+2. Call Google's UAPP API with these parameters
+3. Use Google's encryption keys to generate the Google OPC
+4. Return both the TSP OPC and Google OPC to your app. For Visa tokenization on Fitbit, the TSP OPC must include the `tokenRequestorId`, or the request fails
+
+Example backend response:
+```json
+{
+ "tspOpc": "original-opaque-payment-card-from-tsp",
+ "googleOpc": "encrypted-google-opaque-payment-card"
+}
+```
+
+## Related Data Types
+
+### `onPaymentCredentialsRequestPayload`
+
+Payload received when Google Wallet requests payment credentials during UAPP provisioning:
+
+```typescript
+{
+ requestId: string; // Unique identifier for this request
+ serverSessionId: string; // Session ID from Google (crucial for backend)
+ walletId: string; // Google Wallet ID
+ tokenRequestorId: string | null; // Token Requestor ID of the tokenization target (needed for Visa on Fitbit)
+ opaquePaymentCard: string; // Original OPC from TSP
+}
+```
+
+### `AndroidPaymentCredentialsResponse`
+
+Response object containing encrypted payment credentials for UAPP flow:
+
+```typescript
+{
+ opaquePaymentCard: string; // TSP Opaque Payment Card
+ googleOpaquePaymentCard: string; // Encrypted Google OPC
+}
+```
+
+## Required Data Elements
+
+For UAPP provisioning, you need:
+
+- **Opaque Payment Card** (OPC) - a binary blob of information Google Pay receives from the issuer app that could be presented to TSP to receive a token.
+- **Google Opaque Payment Card** (Google OPC) - an encrypted version of the OPC required for UAPP (Unified Android Push Provisioning) flow. This is generated by your backend using Google's encryption keys during the provisioning process.
+
+## API Reference
+
+### `addCardToGoogleWallet()`
+
+Initiates the Google Wallet card provisioning flow with UAPP support.
+
+**Parameters:**
+- `cardData: AndroidCardData` - Card information required for provisioning
+- `handlePaymentCredentialsGeneration: (data: onPaymentCredentialsRequestPayload) => Promise` - Handler function that receives the payment credentials request and returns encrypted credentials from your backend
+
+**Returns:** `Promise` - Status of the tokenization process ('success', 'canceled', or 'error')
+
+The handler is only called when Google Wallet requests a Google OPC, and it must return both `opaquePaymentCard` and `googleOpaquePaymentCard`. If it throws, returns no Google OPC, or does not resolve within 60 seconds, the request is reported to Google Wallet as failed (`GeneratePaymentCredentialsException.UNKNOWN_ERROR`). Google does not guarantee a fallback to TSP-only provisioning in that case, so do not rely on the card being added.
+
+**Platform:** Android only
+
+**Example:**
+```typescript
+const status = await addCardToGoogleWallet(
+ cardData,
+ async (data) => {
+ const credentials = await fetchCredentialsFromBackend(data);
+ return credentials;
+ }
+);
+```
+
+### AndroidCardData Optional Fields
+
+When using UAPP with `addCardToGoogleWallet()`, the `AndroidCardData` object supports two optional boolean fields:
+
+- **`isVirtualCard?: boolean`** - Set to `true` if provisioning a virtual card (digitally-generated card number linked to a physical card account). Virtual cards provide enhanced security for online transactions.
+
+- **`isBounceProvisioned?: boolean`** - Set to `true` if the card was initially provisioned through Google Wallet and is now being completed through your app. This is used in bounce provisioning flows where users start in Google Wallet and are redirected to your app for additional verification.
+
+For more details, see the [AndroidCardData Optional Fields](../README.md#androidcarddata-optional-fields) section in the main documentation.
+
+## Testing
+
+> [!WARNING]
+> **UAPP implementation must be tested before production use.** Failure to provide required test evidence may result in revoking access to your production environment. Please make sure your implementation is thoroughly tested and approved by Google.
+
+When implementing UAPP, ensure your app meets all Google testing criteria:
+
+1. **UAPP Flow Testing** - Verify that payment credentials are correctly generated and transmitted during provisioning
+2. **Backend Integration** - Ensure your backend correctly generates Google OPC using Google's encryption keys
+3. **Error Handling** - Test timeout scenarios and credential validation failures
+4. **Security Compliance** - Verify secure handling of encrypted payment credentials
+
+For comprehensive testing requirements, refer to:
+- [Google Push Provisioning Testing Criteria](https://developers.google.com/pay/issuers/apis/push-provisioning/android/test-cases)
+- [UAPP-specific Testing Guidelines](https://developers.google.com/pay/issuers/apis/push-provisioning/android/upgrade_to_upp#functional_test_cases)
+- Main documentation [Publishing your app](../README.md#publishing-your-app) section
+
+## Troubleshooting
+
+### Provisioning fails with timeout
+
+If the backend request takes too long, Google Wallet may timeout. Ensure your backend responds within a reasonable timeframe (typically < 10 seconds).
+
+### Invalid credentials error
+
+Verify that:
+1. Your backend is correctly generating the Google OPC using Google's encryption keys
+2. Both `opaquePaymentCard` and `googleOpaquePaymentCard` are returned in the response
+3. The credentials match the card being provisioned
diff --git a/ios/RNWallet.mm b/ios/RNWallet.mm
index 4981b78a..79d8b56d 100644
--- a/ios/RNWallet.mm
+++ b/ios/RNWallet.mm
@@ -135,6 +135,36 @@ - (void)addCardToGoogleWallet:
// no-op
}
+- (void)hasEligibleTokenizationTarget:(NSString *)last4Digits
+ network:(NSString *)network
+ issuerName:(NSString *)issuerName
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject
+{
+ // no-op
+}
+
+- (void)AndroidProvidePaymentCredentials:(NSString *)requestId
+ responseData:
+#ifdef RCT_NEW_ARCH_ENABLED
+ (JS::NativeWallet::AndroidPaymentCredentialsResponse &)responseData
+#else
+ (NSDictionary *)responseData
+#endif
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject
+{
+ // no-op
+}
+
+- (void)AndroidRejectPaymentCredentials:(NSString *)requestId
+ errorMessage:(NSString *)errorMessage
+ resolve:(RCTPromiseResolveBlock)resolve
+ reject:(RCTPromiseRejectBlock)reject
+{
+ // no-op
+}
+
- (void)getSecureWalletInfo:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
// no-op
}
diff --git a/package-lock.json b/package-lock.json
index 812ab2b3..e3c8efbe 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@expensify/react-native-wallet",
- "version": "0.1.41",
+ "version": "0.2.0-0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@expensify/react-native-wallet",
- "version": "0.1.41",
+ "version": "0.2.0-0",
"license": "MIT",
"workspaces": [
"apps/common-app",
diff --git a/package.json b/package.json
index 455db5aa..9450b664 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@expensify/react-native-wallet",
- "version": "0.1.41",
+ "version": "0.2.0-0",
"description": "A React Native module designed for seamless integration of Card Push Provisioning into Apple Wallet and Google Wallet, enabling easy and secure addition of payment cards directly from your application.",
"source": "./src/index.tsx",
"main": "lib/commonjs/index",
diff --git a/src/NativeWallet.ts b/src/NativeWallet.ts
index f15df6e9..e4100c74 100644
--- a/src/NativeWallet.ts
+++ b/src/NativeWallet.ts
@@ -27,6 +27,8 @@ type AndroidCardData = {
cardHolderName: string;
lastDigits: string;
userAddress: UserAddress;
+ isVirtualCard?: boolean;
+ isBounceProvisioned?: boolean;
};
type AndroidResumeCardData = {
@@ -44,10 +46,18 @@ type IOSCardData = {
};
type onCardActivatedPayload = {
- tokenId: string;
+ tokenId?: string | null;
status: 'activated' | 'canceled';
};
+type onPaymentCredentialsRequestPayload = {
+ requestId: string;
+ serverSessionId: string;
+ walletId: string;
+ tokenRequestorId: string | null;
+ opaquePaymentCard: string;
+};
+
type IOSAddPaymentPassData = {
status: number;
nonce: string;
@@ -69,13 +79,21 @@ type TokenInfo = {
tokenState: number;
};
+type AndroidPaymentCredentialsResponse = {
+ opaquePaymentCard: string;
+ googleOpaquePaymentCard: string;
+};
+
export interface Spec extends TurboModule {
checkWalletAvailability(): Promise;
ensureGoogleWalletInitialized(): Promise;
getSecureWalletInfo(): Promise;
getCardStatusBySuffix(last4Digits: string): Promise;
getCardStatusByIdentifier(identifier: string, tsp: string): Promise;
+ hasEligibleTokenizationTarget(last4Digits: string, network: string, issuerName: string): Promise;
addCardToGoogleWallet(cardData: AndroidCardData): Promise;
+ AndroidProvidePaymentCredentials(requestId: string, responseData: AndroidPaymentCredentialsResponse): Promise;
+ AndroidRejectPaymentCredentials(requestId: string, errorMessage: string): Promise;
resumeAddCardToGoogleWallet(cardData: AndroidResumeCardData): Promise;
listTokens(): Promise;
IOSPresentAddPaymentPassView(cardData: IOSCardData): Promise;
@@ -107,9 +125,11 @@ export type {
CardStatus,
UserAddress,
onCardActivatedPayload,
+ onPaymentCredentialsRequestPayload,
Platform,
IOSAddPaymentPassData,
IOSEncryptPayload,
TokenizationStatus,
TokenInfo,
+ AndroidPaymentCredentialsResponse,
};
diff --git a/src/index.tsx b/src/index.tsx
index d1801ffc..ec3d62bb 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable no-console */
/* eslint-disable @lwc/lwc/no-async-await */
import {NativeEventEmitter, Platform} from 'react-native';
import type {EmitterSubscription} from 'react-native';
@@ -11,8 +12,10 @@ import type {
IOSEncryptPayload,
AndroidWalletData,
onCardActivatedPayload,
+ onPaymentCredentialsRequestPayload,
IOSAddPaymentPassData,
TokenInfo,
+ AndroidPaymentCredentialsResponse,
} from './NativeWallet';
import {getCardState, getTokenizationStatus} from './utils';
import AddToWalletButton from './AddToWalletButton';
@@ -22,6 +25,25 @@ function getModuleLinkingRejection() {
}
let eventEmitter: NativeEventEmitter | undefined;
+let paymentCredentialsHandler: ((data: onPaymentCredentialsRequestPayload) => Promise) | null = null;
+
+if (Platform.OS === 'android' && Wallet) {
+ eventEmitter ??= new NativeEventEmitter(Wallet);
+ eventEmitter.addListener('onPaymentCredentialsRequest', async (data: onPaymentCredentialsRequestPayload) => {
+ if (!paymentCredentialsHandler) {
+ console.warn(`[${PACKAGE_NAME}] Received payment credentials request but no handler is set`);
+ await Wallet?.AndroidRejectPaymentCredentials(data.requestId, 'No payment credentials handler is set').catch(() => undefined);
+ return;
+ }
+ try {
+ const responseData = await paymentCredentialsHandler(data);
+ await Wallet?.AndroidProvidePaymentCredentials(data.requestId, responseData);
+ } catch (error) {
+ console.error(`[${PACKAGE_NAME}] Error handling payment credentials request:`, error);
+ await Wallet?.AndroidRejectPaymentCredentials(data.requestId, error instanceof Error ? error.message : String(error)).catch(() => undefined);
+ }
+ });
+}
function addListener(event: string, callback: (data: onCardActivatedPayload) => void): EmitterSubscription {
if (!Wallet) {
@@ -84,7 +106,30 @@ async function getCardStatusByIdentifier(identifier: string, tsp: string): Promi
return getCardState(tokenState);
}
-async function addCardToGoogleWallet(cardData: AndroidCardData): Promise {
+/**
+ * Checks whether the card can still be added to a Google Wallet target: the phone or a paired wearable.
+ * Use it to decide whether to show the "Add to Google Wallet" button when wearables are supported.
+ * @param last4Digits - The last 4 digits of the card number (FPAN, not the token)
+ * @param network - The card network, e.g. `VISA`, `MASTERCARD`
+ * @param issuerName - The issuer name used for matching, as in the token metadata (not the card display name)
+ * @returns `true` if at least one target can still receive the card
+ */
+async function hasEligibleTokenizationTarget(last4Digits: string, network: string, issuerName: string): Promise {
+ if (Platform.OS === 'ios') {
+ throw new Error('hasEligibleTokenizationTarget is not available on iOS');
+ }
+
+ if (!Wallet) {
+ return getModuleLinkingRejection();
+ }
+
+ return Wallet.hasEligibleTokenizationTarget(last4Digits, network.toUpperCase(), issuerName);
+}
+
+async function addCardToGoogleWallet(
+ cardData: AndroidCardData,
+ handlePaymentCredentialsGeneration: (data: onPaymentCredentialsRequestPayload) => Promise,
+): Promise {
if (Platform.OS === 'ios') {
throw new Error('addCardToGoogleWallet is not available on iOS');
}
@@ -96,8 +141,14 @@ async function addCardToGoogleWallet(cardData: AndroidCardData): Promise {
@@ -164,13 +215,26 @@ async function addCardToAppleWallet(
return getTokenizationStatus(status);
}
-export type {AndroidCardData, AndroidWalletData, CardStatus, IOSEncryptPayload, IOSCardData, IOSAddPaymentPassData, onCardActivatedPayload, TokenizationStatus, TokenInfo};
+export type {
+ AndroidCardData,
+ AndroidWalletData,
+ CardStatus,
+ IOSEncryptPayload,
+ IOSCardData,
+ IOSAddPaymentPassData,
+ onCardActivatedPayload,
+ onPaymentCredentialsRequestPayload,
+ TokenizationStatus,
+ TokenInfo,
+ AndroidPaymentCredentialsResponse,
+};
export {
AddToWalletButton,
checkWalletAvailability,
getSecureWalletInfo,
getCardStatusBySuffix,
getCardStatusByIdentifier,
+ hasEligibleTokenizationTarget,
addCardToGoogleWallet,
resumeAddCardToGoogleWallet,
listTokens,