From 24858c079f816222a8a6b8da077ea018945512d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Thu, 12 Feb 2026 15:26:13 +0100 Subject: [PATCH 01/26] Add base PaymentCredentialsGenerator --- .../java/com/expensify/wallet/WalletModule.kt | 66 +++++++++++++++---- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index 368f31f2..9d114a86 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -5,6 +5,14 @@ import android.app.Activity.RESULT_CANCELED import android.app.Activity.RESULT_OK import android.content.ActivityNotFoundException import android.content.Intent +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,23 +23,22 @@ 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.GeneratePaymentCredentialsRequest +import com.google.android.gms.tapandpay.issuer.GeneratePaymentCredentialsResponse +import com.google.android.gms.tapandpay.issuer.PaymentCredentialsGenerator import com.google.android.gms.tapandpay.issuer.PushTokenizeRequest import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -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 java.nio.charset.Charset import java.util.Locale +import java.util.concurrent.Callable +import java.util.concurrent.Future +import java.util.concurrent.FutureTask class WalletModule internal constructor(context: ReactApplicationContext) : @@ -198,6 +205,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) : .setDisplayName(displayName) .setLastDigits(cardData.lastDigits) .setUserAddress(cardData.userAddress) + .setPaymentCredentialsGenerator(createPaymentCredentialsGenerator(cardData)) .build() tapAndPayClient.pushTokenize( @@ -208,6 +216,38 @@ class WalletModule internal constructor(context: ReactApplicationContext) : } } + private fun createPaymentCredentialsGenerator( + cardData: CardData + ): PaymentCredentialsGenerator { + return object : PaymentCredentialsGenerator { + override fun generate(request: GeneratePaymentCredentialsRequest): Future { + val callable = Callable { + val tspOpcBytes = cardData.opaquePaymentCard.toByteArray(Charsets.UTF_8) + var googleOpcBytes: ByteArray? = null + + if (request.googleOpaquePaymentCardRequested) { + googleOpcBytes = cardData.googleOpaquePaymentCard?.toByteArray(Charsets.UTF_8) + } + + GeneratePaymentCredentialsResponse.Builder() + .setOpaquePaymentCard(tspOpcBytes) + .setGoogleOpaquePaymentCard(googleOpcBytes) + .build() + } + + val futureTask = FutureTask(callable) + // Run it immediately (since we have the data already) + futureTask.run() + return futureTask + } + + // This switch enables the UAPP flow + override fun getGoogleOpaquePaymentCardSupported(): Boolean { + return true + } + } + } + @ReactMethod override fun resumeAddCardToGoogleWallet(data: ReadableMap, promise: Promise) { try { @@ -243,7 +283,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) : promise.resolve(Arguments.createArray()) return@addOnCompleteListener } - + val tokensArray = Arguments.createArray() task.result.forEach { tokenInfo -> val tokenData = Arguments.createMap().apply { @@ -253,7 +293,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) : } tokensArray.pushMap(tokenData) } - + promise.resolve(tokensArray) } .addOnFailureListener { e -> @@ -330,13 +370,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" } From c82af877d33c20e36ba783d5dec95982d75c9400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Thu, 12 Feb 2026 16:33:19 +0100 Subject: [PATCH 02/26] Update Push Tokenization results handling --- .../java/com/expensify/wallet/WalletModule.kt | 62 ++++++++++++++----- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index 9d114a86..f84cb179 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -5,6 +5,9 @@ import android.app.Activity.RESULT_CANCELED import android.app.Activity.RESULT_OK import android.content.ActivityNotFoundException import android.content.Intent +import android.content.IntentSender +import android.util.Log +import androidx.core.content.IntentCompat import com.expensify.wallet.Utils.getAsyncResult import com.expensify.wallet.Utils.toCardData import com.expensify.wallet.error.InvalidNetworkError @@ -26,10 +29,13 @@ 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.TapAndPayStatusCodes import com.google.android.gms.tapandpay.issuer.GeneratePaymentCredentialsRequest import com.google.android.gms.tapandpay.issuer.GeneratePaymentCredentialsResponse import com.google.android.gms.tapandpay.issuer.PaymentCredentialsGenerator import com.google.android.gms.tapandpay.issuer.PushTokenizeRequest +import com.google.android.gms.tapandpay.issuer.PushTokenizeResult +import com.google.android.gms.tapandpay.issuer.TokenizationOutcome import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers @@ -76,23 +82,39 @@ class WalletModule internal constructor(context: ReactApplicationContext) : pendingCreateWalletPromise?.resolve(resultCode == RESULT_OK) pendingCreateWalletPromise = null } else if (requestCode == REQUEST_CODE_PUSH_TOKENIZE) { - if (resultCode == RESULT_OK) { - data?.let { - val tokenId = it.getStringExtra(TapAndPay.EXTRA_ISSUER_TOKEN_ID).toString() - sendEvent( - context, - OnCardActivatedEvent.NAME, - OnCardActivatedEvent("active", tokenId).toMap() - ) - pendingPushTokenizePromise?.resolve(TokenizationStatus.SUCCESS.code) - } - } else if (resultCode == RESULT_CANCELED) { + if (resultCode == RESULT_CANCELED) { sendEvent( context, OnCardActivatedEvent.NAME, OnCardActivatedEvent("canceled", null).toMap() ) pendingPushTokenizePromise?.resolve(TokenizationStatus.CANCELED.code) + return + } + + if (data != null) { + val result = + IntentCompat.getParcelableExtra( + data, TapAndPay.EXTRA_PUSH_TOKENIZE_RESULT, PushTokenizeResult::class.java + ) + + if (result != null) { + val isSavedToCloud = result.cardResult + val tokenOutcomes = result.tokenizationOutcomes + + if (isSavedToCloud || tokenOutcomes.isNotEmpty()) { + val tokenId = tokenOutcomes.firstOrNull()?.issuerTokenId ?: "card_on_file_only" + + sendEvent(context, OnCardActivatedEvent.NAME, OnCardActivatedEvent("active", tokenId).toMap()) + pendingPushTokenizePromise?.resolve(TokenizationStatus.SUCCESS.code) + } else { + val errorMsg = "Card not saved. Status: ${result.cardStatus}" + pendingPushTokenizePromise?.reject(E_OPERATION_FAILED, errorMsg) + } + } else { + // Data intent is null (rare but possible failure case). Report to Google if you observe this. + pendingPushTokenizePromise?.reject(E_OPERATION_FAILED, "Unexpected error.") + } } } } @@ -208,9 +230,21 @@ class WalletModule internal constructor(context: ReactApplicationContext) : .setPaymentCredentialsGenerator(createPaymentCredentialsGenerator(cardData)) .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: IntentSender.SendIntentException) { + promise.reject(E_OPERATION_FAILED, "Failed to launch Google Pay: ${e.message}") + } + } + .addOnFailureListener { e -> + promise.reject(E_OPERATION_FAILED, "Google Pay API Error: ${e.message}") + } } catch (e: java.lang.Exception) { promise.reject(e) } From e1af64efc278d6a8023e8c9db1183cb89792da40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Fri, 13 Feb 2026 12:16:48 +0100 Subject: [PATCH 03/26] Add UAPP new props --- android/src/main/java/com/expensify/wallet/Utils.kt | 8 +++++++- .../main/java/com/expensify/wallet/WalletModule.kt | 11 ++++++++--- .../main/java/com/expensify/wallet/model/CardData.kt | 5 ++++- apps/common-app/src/CONST.ts | 2 ++ src/NativeWallet.ts | 2 ++ 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/com/expensify/wallet/Utils.kt b/android/src/main/java/com/expensify/wallet/Utils.kt index 6bb09ef4..363ab7e8 100644 --- a/android/src/main/java/com/expensify/wallet/Utils.kt +++ b/android/src/main/java/com/expensify/wallet/Utils.kt @@ -29,12 +29,18 @@ 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") ?: "", + googleOpaquePaymentCard = this.getString("googleOpaquePaymentCard") ?: "", 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 f84cb179..04d2c22d 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -29,13 +29,12 @@ 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.TapAndPayStatusCodes import com.google.android.gms.tapandpay.issuer.GeneratePaymentCredentialsRequest import com.google.android.gms.tapandpay.issuer.GeneratePaymentCredentialsResponse 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.TokenizationOutcome import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers @@ -220,6 +219,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) @@ -228,6 +232,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) : .setLastDigits(cardData.lastDigits) .setUserAddress(cardData.userAddress) .setPaymentCredentialsGenerator(createPaymentCredentialsGenerator(cardData)) + .setPushTokenizeExtraOptions(pushTokenizeExtraOptions) .build() tapAndPayClient.pushTokenize(pushTokenizeRequest) @@ -260,7 +265,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) : var googleOpcBytes: ByteArray? = null if (request.googleOpaquePaymentCardRequested) { - googleOpcBytes = cardData.googleOpaquePaymentCard?.toByteArray(Charsets.UTF_8) + googleOpcBytes = cardData.googleOpaquePaymentCard?.toByteArray(Charsets.UTF_8) } GeneratePaymentCredentialsResponse.Builder() 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..419b2b2a 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,11 @@ 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 googleOpaquePaymentCard: String? = null, val cardHolderName: String, val lastDigits: String, val userAddress: UserAddress, + val isVirtualCard: Boolean = false, + val isBounceProvisioned: Boolean = false, ) diff --git a/apps/common-app/src/CONST.ts b/apps/common-app/src/CONST.ts index 8752ef19..6f20632e 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 = { diff --git a/src/NativeWallet.ts b/src/NativeWallet.ts index cf8a47d7..8d01bb9a 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 = { From 54cd3fe8aae8e3667b43a56f1a2c444aa3ca2b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Fri, 13 Feb 2026 15:58:31 +0100 Subject: [PATCH 04/26] Add ability to fetch GOPC in RN during push provisioning --- .../java/com/expensify/wallet/WalletModule.kt | 69 ++++++++++++++----- apps/common-app/src/CONST.ts | 7 +- apps/common-app/src/walletUtils.ts | 10 ++- src/NativeWallet.ts | 15 ++++ src/index.tsx | 41 ++++++++++- 5 files changed, 120 insertions(+), 22 deletions(-) diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index 04d2c22d..e9a82dca 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -6,7 +6,6 @@ import android.app.Activity.RESULT_OK import android.content.ActivityNotFoundException import android.content.Intent import android.content.IntentSender -import android.util.Log import androidx.core.content.IntentCompat import com.expensify.wallet.Utils.getAsyncResult import com.expensify.wallet.Utils.toCardData @@ -41,9 +40,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.nio.charset.Charset import java.util.Locale -import java.util.concurrent.Callable +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Future -import java.util.concurrent.FutureTask class WalletModule internal constructor(context: ReactApplicationContext) : @@ -63,6 +63,8 @@ class WalletModule internal constructor(context: ReactApplicationContext) : private var pendingCreateWalletPromise: Promise? = null private var pendingPushTokenizePromise: Promise? = null + private val pendingProvisioningFutures = ConcurrentHashMap>() + override fun initialize() { super.initialize() reactApplicationContext.addActivityEventListener(cardListener) @@ -260,24 +262,28 @@ class WalletModule internal constructor(context: ReactApplicationContext) : ): PaymentCredentialsGenerator { return object : PaymentCredentialsGenerator { override fun generate(request: GeneratePaymentCredentialsRequest): Future { - val callable = Callable { - val tspOpcBytes = cardData.opaquePaymentCard.toByteArray(Charsets.UTF_8) - var googleOpcBytes: ByteArray? = null + if (!request.googleOpaquePaymentCardRequested) { + val response = GeneratePaymentCredentialsResponse.Builder() + .setOpaquePaymentCard(cardData.opaquePaymentCard.toByteArray(Charsets.UTF_8)) + .build() - if (request.googleOpaquePaymentCardRequested) { - googleOpcBytes = cardData.googleOpaquePaymentCard?.toByteArray(Charsets.UTF_8) - } + return CompletableFuture.completedFuture(response) + } - GeneratePaymentCredentialsResponse.Builder() - .setOpaquePaymentCard(tspOpcBytes) - .setGoogleOpaquePaymentCard(googleOpcBytes) - .build() + val future = CompletableFuture() + val requestId = UUID.randomUUID().toString() + pendingProvisioningFutures[requestId] = future + + val params = Arguments.createMap().apply { + putString("requestId", requestId) + putString("serverSessionId", request.serverSessionId) + putString("walletId", request.walletId) + putString("opaquePaymentCard", cardData.opaquePaymentCard) } - val futureTask = FutureTask(callable) - // Run it immediately (since we have the data already) - futureTask.run() - return futureTask + // 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 @@ -287,6 +293,35 @@ class WalletModule internal constructor(context: ReactApplicationContext) : } } + @ReactMethod + override fun AndroidProvidePaymentCredentials(requestId: String, responseData: ReadableMap, promise: Promise) { + val future = pendingProvisioningFutures.remove(requestId) + if (future == 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") + } + val response = GeneratePaymentCredentialsResponse.Builder() + .setGoogleOpaquePaymentCard(googleOpc?.toByteArray(Charsets.UTF_8)) + .setOpaquePaymentCard(tspOpc.toByteArray(Charsets.UTF_8)) + .build() + + future.complete(response) + promise.resolve(true) + + } catch (e: Exception) { + future.completeExceptionally(e) + promise.reject(E_OPERATION_FAILED, e.message) + } + } + @ReactMethod override fun resumeAddCardToGoogleWallet(data: ReadableMap, promise: Promise) { try { diff --git a/apps/common-app/src/CONST.ts b/apps/common-app/src/CONST.ts index 6f20632e..66fbb15e 100644 --- a/apps/common-app/src/CONST.ts +++ b/apps/common-app/src/CONST.ts @@ -42,4 +42,9 @@ const IOSDummyEncryptPayload: IOSEncryptPayload = { ephemeralPublicKey: 'ZXBoZW1lcmFsUHVibGljS2V5MTIz', }; -export {AndroidDummyCardData, AndroidDummyResumeCardData, IOSDummyCardData, IOSDummyEncryptPayload}; +const IOSDummyPaymentCredentialsResponse = { + opaquePaymentCard: 'encryptedCardInformation123456', + googleOpaquePaymentCard: 'GOPC', +}; + +export {AndroidDummyCardData, AndroidDummyResumeCardData, IOSDummyCardData, IOSDummyEncryptPayload, IOSDummyPaymentCredentialsResponse}; diff --git a/apps/common-app/src/walletUtils.ts b/apps/common-app/src/walletUtils.ts index 2d5769bf..6ef9f961 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.IOSDummyPaymentCredentialsResponse); +} + 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/src/NativeWallet.ts b/src/NativeWallet.ts index 8d01bb9a..c9350bde 100644 --- a/src/NativeWallet.ts +++ b/src/NativeWallet.ts @@ -50,6 +50,13 @@ type onCardActivatedPayload = { actionStatus: 'active' | 'canceled'; }; +type onPaymentCredentialsRequestPayload = { + requestId: string; + serverSessionId: string; + walletId: string; + opaquePaymentCard: string; +}; + type IOSAddPaymentPassData = { status: number; nonce: string; @@ -71,6 +78,11 @@ type TokenInfo = { tokenState: number; }; +type AndroidPaymentCredentialsResponse = { + opaquePaymentCard: string; + googleOpaquePaymentCard: string; +}; + export interface Spec extends TurboModule { checkWalletAvailability(): Promise; ensureGoogleWalletInitialized(): Promise; @@ -78,6 +90,7 @@ export interface Spec extends TurboModule { getCardStatusBySuffix(last4Digits: string): Promise; getCardStatusByIdentifier(identifier: string, tsp: string): Promise; addCardToGoogleWallet(cardData: AndroidCardData): Promise; + AndroidProvidePaymentCredentials(requestId: string, responseData: AndroidPaymentCredentialsResponse): Promise; resumeAddCardToGoogleWallet(cardData: AndroidResumeCardData): Promise; listTokens(): Promise; IOSPresentAddPaymentPassView(cardData: IOSCardData): Promise; @@ -109,9 +122,11 @@ export type { CardStatus, UserAddress, onCardActivatedPayload, + onPaymentCredentialsRequestPayload, Platform, IOSAddPaymentPassData, IOSEncryptPayload, TokenizationStatus, TokenInfo, + AndroidPaymentCredentialsResponse, }; diff --git a/src/index.tsx b/src/index.tsx index 1f947564..47aa179c 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,22 @@ function getModuleLinkingRejection() { } const eventEmitter = new NativeEventEmitter(Wallet); +let paymentCredentialsHandler: ((data: onPaymentCredentialsRequestPayload) => Promise) | null = null; + +if (Platform.OS === 'android' && Wallet) { + eventEmitter.addListener('onPaymentCredentialsRequest', async (data: onPaymentCredentialsRequestPayload) => { + if (paymentCredentialsHandler) { + try { + const responseData = await paymentCredentialsHandler(data); + await Wallet?.AndroidProvidePaymentCredentials(data.requestId, responseData); + } catch (error) { + console.error('[react-native-wallet] Error handling payment credentials request:', error); + } + } else { + console.warn('[react-native-wallet] Received payment credentials request but no handler is set. Call setPaymentCredentialsHandler() to handle this event.'); + } + }); +} function addListener(event: string, callback: (data: onCardActivatedPayload) => void): EmitterSubscription { return eventEmitter.addListener(event, callback); @@ -78,7 +97,10 @@ async function getCardStatusByIdentifier(identifier: string, tsp: string): Promi return getCardState(tokenState); } -async function addCardToGoogleWallet(cardData: AndroidCardData): Promise { +async function addCardToGoogleWallet( + cardData: AndroidCardData, + handlePaymentCredentialsGeneration: (data: onPaymentCredentialsRequestPayload) => Promise, +): Promise { if (Platform.OS === 'ios') { throw new Error('addCardToGoogleWallet is not available on iOS'); } @@ -90,7 +112,10 @@ async function addCardToGoogleWallet(cardData: AndroidCardData): Promise Date: Fri, 13 Feb 2026 15:59:04 +0100 Subject: [PATCH 05/26] Add new function to paper spec --- .../src/paper/java/com/expensify/wallet/NativeWalletSpec.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java b/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java index 999f0cb8..5cd1bfa4 100644 --- a/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java +++ b/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java @@ -57,6 +57,10 @@ 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 resumeAddCardToGoogleWallet(ReadableMap cardData, Promise promise); From 3412195fe208d15c40111bb02e77413cbe0a7215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Ska=C5=82ka?= Date: Mon, 16 Feb 2026 11:18:38 +0100 Subject: [PATCH 06/26] Update docs --- README.md | 61 ++++++++++++++++-- docs/UAPP.md | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 docs/UAPP.md diff --git a/README.md b/README.md index 8adbce80..c8247077 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 (User Account Payment Provisioning) support for enhanced security - πŸŽ‰ Expo support (expo-plugin) ## Getting started @@ -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 (User Account Payment 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,19 +180,20 @@ 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` | ❌ | βœ… | +| **addCardToGoogleWallet** | Initiates native Push Provisioning flow for adding a card to the Google Wallet. For UAPP flow, pass a handler as the second parameter. [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`,
`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`,
`actionStatus: 'activated' \| 'canceled'`
| @@ -198,6 +201,27 @@ The library offers seven functions for seamless integration and use of the Apple | **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 +307,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 (User Account Payment Provisioning) + +For Android applications that require enhanced security for card provisioning, this library supports UAPP (User Account Payment 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/docs/UAPP.md b/docs/UAPP.md new file mode 100644 index 00000000..acb3b857 --- /dev/null +++ b/docs/UAPP.md @@ -0,0 +1,174 @@ +# UAPP (User Account Payment Provisioning) for Android + +## What is UAPP? + +UAPP (User Account Payment 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 + +To enable UAPP support, pass a payment credentials handler directly to the `addCardToGoogleWallet()` function: + +```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`, and `walletId` +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 + +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 + 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 (User Account Payment 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') + +**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 From 31c1613213fc153f154b3ac9ef5b7acab415e14b Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 8 Oct 2026 14:56:28 +0200 Subject: [PATCH 07/26] fix: clear pending tokenize promise on async pushTokenize failures --- android/src/main/java/com/expensify/wallet/WalletModule.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index 86a4c6f6..0b4d7e7d 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -5,7 +5,6 @@ import android.app.Activity.RESULT_CANCELED import android.app.Activity.RESULT_OK import android.content.ActivityNotFoundException import android.content.Intent -import android.content.IntentSender import androidx.core.content.IntentCompat import com.expensify.wallet.Utils.getAsyncResult import com.expensify.wallet.Utils.toCardData @@ -251,11 +250,13 @@ class WalletModule internal constructor(context: ReactApplicationContext) : REQUEST_CODE_PUSH_TOKENIZE, null, 0, 0, 0 ) - } catch (e: IntentSender.SendIntentException) { + } 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) { From 8283655bb1dbe94b705554967e8b7e4c0e66092d Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 8 Oct 2026 15:02:08 +0200 Subject: [PATCH 08/26] fix: settle pending payment credentials requests on error, timeout and invalidate --- .../java/com/expensify/wallet/WalletModule.kt | 24 +++++++++++++++ .../expensify/wallet/NativeWalletSpec.java | 4 +++ ios/RNWallet.mm | 21 ++++++++++++++ src/NativeWallet.ts | 1 + src/index.tsx | 29 +++++++++++-------- 5 files changed, 67 insertions(+), 12 deletions(-) diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index 0b4d7e7d..2eec63cb 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -5,6 +5,8 @@ import android.app.Activity.RESULT_CANCELED import android.app.Activity.RESULT_OK import android.content.ActivityNotFoundException import android.content.Intent +import android.os.Handler +import android.os.Looper import androidx.core.content.IntentCompat import com.expensify.wallet.Utils.getAsyncResult import com.expensify.wallet.Utils.toCardData @@ -45,6 +47,7 @@ import java.util.UUID import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Future +import java.util.concurrent.TimeoutException class WalletModule internal constructor(context: ReactApplicationContext) : @@ -57,6 +60,8 @@ class WalletModule internal constructor(context: ReactApplicationContext) : 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 @@ -69,6 +74,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) : private val moduleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val pendingProvisioningFutures = ConcurrentHashMap>() + private val mainHandler = Handler(Looper.getMainLooper()) override fun initialize() { super.initialize() @@ -79,6 +85,9 @@ class WalletModule internal constructor(context: ReactApplicationContext) : super.invalidate() moduleScope.cancel() reactApplicationContext.removeActivityEventListener(cardListener) + mainHandler.removeCallbacksAndMessages(null) + pendingProvisioningFutures.values.forEach { it.completeExceptionally(IllegalStateException("Wallet module invalidated")) } + pendingProvisioningFutures.clear() } private val cardListener = object : ActivityEventListener { @@ -281,6 +290,10 @@ class WalletModule internal constructor(context: ReactApplicationContext) : val future = CompletableFuture() val requestId = UUID.randomUUID().toString() pendingProvisioningFutures[requestId] = future + mainHandler.postDelayed({ + pendingProvisioningFutures.remove(requestId) + ?.completeExceptionally(TimeoutException("Payment credentials were not provided in time")) + }, PAYMENT_CREDENTIALS_TIMEOUT_MS) val params = Arguments.createMap().apply { putString("requestId", requestId) @@ -330,6 +343,17 @@ class WalletModule internal constructor(context: ReactApplicationContext) : } } + @ReactMethod + override fun AndroidRejectPaymentCredentials(requestId: String, errorMessage: String, promise: Promise) { + val future = pendingProvisioningFutures.remove(requestId) + if (future == null) { + promise.reject(E_OPERATION_FAILED, "Request ID not found or timed out") + return + } + future.completeExceptionally(Exception(errorMessage)) + promise.resolve(true) + } + @ReactMethod override fun resumeAddCardToGoogleWallet(data: ReadableMap, promise: Promise) { if (pendingPushTokenizePromise != null) { diff --git a/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java b/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java index 5cd1bfa4..052fb38b 100644 --- a/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java +++ b/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java @@ -61,6 +61,10 @@ public NativeWalletSpec(ReactApplicationContext reactContext) { @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/ios/RNWallet.mm b/ios/RNWallet.mm index 4981b78a..a17297c7 100644 --- a/ios/RNWallet.mm +++ b/ios/RNWallet.mm @@ -135,6 +135,27 @@ - (void)addCardToGoogleWallet: // 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/src/NativeWallet.ts b/src/NativeWallet.ts index 9052a226..f06dd397 100644 --- a/src/NativeWallet.ts +++ b/src/NativeWallet.ts @@ -91,6 +91,7 @@ export interface Spec extends TurboModule { getCardStatusByIdentifier(identifier: string, tsp: 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; diff --git a/src/index.tsx b/src/index.tsx index 5cdf95e1..3e36ea17 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -30,15 +30,17 @@ let paymentCredentialsHandler: ((data: onPaymentCredentialsRequestPayload) => Pr if (Platform.OS === 'android' && Wallet) { eventEmitter ??= new NativeEventEmitter(Wallet); eventEmitter.addListener('onPaymentCredentialsRequest', async (data: onPaymentCredentialsRequestPayload) => { - if (paymentCredentialsHandler) { - try { - const responseData = await paymentCredentialsHandler(data); - await Wallet?.AndroidProvidePaymentCredentials(data.requestId, responseData); - } catch (error) { - console.error('[react-native-wallet] Error handling payment credentials request:', error); - } - } else { - console.warn('[react-native-wallet] Received payment credentials request but no handler is set. Call setPaymentCredentialsHandler() to handle this event.'); + 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); } }); } @@ -121,9 +123,12 @@ async function addCardToGoogleWallet( } paymentCredentialsHandler = handlePaymentCredentialsGeneration; - const tokenizationStatus = await Wallet.addCardToGoogleWallet(cardData); - paymentCredentialsHandler = null; - return getTokenizationStatus(tokenizationStatus); + try { + const tokenizationStatus = await Wallet.addCardToGoogleWallet(cardData); + return getTokenizationStatus(tokenizationStatus); + } finally { + paymentCredentialsHandler = null; + } } async function resumeAddCardToGoogleWallet(cardData: AndroidResumeCardData): Promise { From 22c5f7eadb7aa8357f953eb1b5b80607af20876b Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 8 Oct 2026 15:02:11 +0200 Subject: [PATCH 09/26] fix: report activation only for successful tokenization outcomes --- android/src/main/java/com/expensify/wallet/WalletModule.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index 2eec63cb..1a3c7d53 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -118,10 +118,10 @@ class WalletModule internal constructor(context: ReactApplicationContext) : if (result != null) { val isSavedToCloud = result.cardResult - val tokenOutcomes = result.tokenizationOutcomes + val successfulOutcome = result.tokenizationOutcomes.firstOrNull { it.tokenResult } - if (isSavedToCloud || tokenOutcomes.isNotEmpty()) { - val tokenId = tokenOutcomes.firstOrNull()?.issuerTokenId ?: "card_on_file_only" + if (isSavedToCloud || successfulOutcome != null) { + val tokenId = successfulOutcome?.issuerTokenId sendEvent(context, OnCardActivatedEvent.NAME, OnCardActivatedEvent("activated", tokenId).toMap()) localPromise?.resolve(TokenizationStatus.SUCCESS.code) From 138c622c432ad54cb7af4957c2f79bd5bc46b67a Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 8 Oct 2026 15:02:55 +0200 Subject: [PATCH 10/26] docs: require payment credentials handler and note tested TapAndPay SDK --- README.md | 6 +++--- docs/UAPP.md | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 67bd2cc9..113fa513 100644 --- a/README.md +++ b/README.md @@ -22,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`. @@ -135,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 }, ], ] @@ -180,7 +180,7 @@ The library offers functions for seamless integration and use of the Apple Walle | **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. For UAPP flow, pass a handler as the second parameter. [Learn more β†’](./docs/UAPP.md) | `data`: `AndroidCardData`,
`handlePaymentCredentials?`: `(data: onPaymentCredentialsRequestPayload) => Promise` | `TokenizationStatus` | ❌ | βœ… | +| **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` | βœ… | ❌ | diff --git a/docs/UAPP.md b/docs/UAPP.md index acb3b857..1c771827 100644 --- a/docs/UAPP.md +++ b/docs/UAPP.md @@ -22,7 +22,7 @@ This library handles the UAPP flow automatically. You just need to set up a hand ## Setup -To enable UAPP support, pass a payment credentials handler directly to the `addCardToGoogleWallet()` function: +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'; @@ -120,6 +120,8 @@ Initiates the Google Wallet card provisioning flow with UAPP support. **Returns:** `Promise` - Status of the tokenization process ('success', 'canceled', or 'error') +The handler is only called when Google Wallet requests a Google OPC. If it throws, the request is reported to Google Wallet as failed. If it does not resolve within 60 seconds, the request fails with a timeout. + **Platform:** Android only **Example:** From 68447d6ae280e17da8e3f2e32eba19a9ada6dbff Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 8 Oct 2026 15:02:57 +0200 Subject: [PATCH 11/26] chore: rename Android dummy credentials and drop unused card field --- android/src/main/java/com/expensify/wallet/Utils.kt | 1 - android/src/main/java/com/expensify/wallet/model/CardData.kt | 1 - apps/common-app/src/CONST.ts | 4 ++-- apps/common-app/src/walletUtils.ts | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/com/expensify/wallet/Utils.kt b/android/src/main/java/com/expensify/wallet/Utils.kt index 9bb76c6d..7bce2507 100644 --- a/android/src/main/java/com/expensify/wallet/Utils.kt +++ b/android/src/main/java/com/expensify/wallet/Utils.kt @@ -35,7 +35,6 @@ object Utils { return CardData( network = this.getString("network") ?: "", opaquePaymentCard = this.getString("opaquePaymentCard") ?: "", - googleOpaquePaymentCard = this.getString("googleOpaquePaymentCard") ?: "", cardHolderName = this.getString("cardHolderName") ?: "", lastDigits = this.getString("lastDigits") ?: "", userAddress = userAddress, 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 419b2b2a..a348ee77 100644 --- a/android/src/main/java/com/expensify/wallet/model/CardData.kt +++ b/android/src/main/java/com/expensify/wallet/model/CardData.kt @@ -6,7 +6,6 @@ data class CardData( val platform: String = "android", val network: String, val opaquePaymentCard: String, // TSP OPC - val googleOpaquePaymentCard: String? = null, val cardHolderName: String, val lastDigits: String, val userAddress: UserAddress, diff --git a/apps/common-app/src/CONST.ts b/apps/common-app/src/CONST.ts index 66fbb15e..e3130925 100644 --- a/apps/common-app/src/CONST.ts +++ b/apps/common-app/src/CONST.ts @@ -42,9 +42,9 @@ const IOSDummyEncryptPayload: IOSEncryptPayload = { ephemeralPublicKey: 'ZXBoZW1lcmFsUHVibGljS2V5MTIz', }; -const IOSDummyPaymentCredentialsResponse = { +const AndroidDummyPaymentCredentialsResponse = { opaquePaymentCard: 'encryptedCardInformation123456', googleOpaquePaymentCard: 'GOPC', }; -export {AndroidDummyCardData, AndroidDummyResumeCardData, IOSDummyCardData, IOSDummyEncryptPayload, IOSDummyPaymentCredentialsResponse}; +export {AndroidDummyCardData, AndroidDummyResumeCardData, IOSDummyCardData, IOSDummyEncryptPayload, AndroidDummyPaymentCredentialsResponse}; diff --git a/apps/common-app/src/walletUtils.ts b/apps/common-app/src/walletUtils.ts index 6ef9f961..8498306e 100644 --- a/apps/common-app/src/walletUtils.ts +++ b/apps/common-app/src/walletUtils.ts @@ -12,7 +12,7 @@ function issuerEncryptPayloadCallback(_nonce: string, _nonceSignature: string, _ 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.IOSDummyPaymentCredentialsResponse); + return Promise.resolve(CONST.AndroidDummyPaymentCredentialsResponse); } async function addCardToWallet(cardStatus?: CardStatus) { From 81f9973b5946137e1af47b08a637ba361befd515 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 8 Oct 2026 15:12:50 +0200 Subject: [PATCH 12/26] fix: keep legacy result handling for resume tokenize flow --- .../java/com/expensify/wallet/WalletModule.kt | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index 1a3c7d53..1baa333f 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -55,6 +55,7 @@ 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" @@ -97,6 +98,27 @@ class WalletModule internal constructor(context: ReactApplicationContext) : if (requestCode == REQUEST_CREATE_WALLET) { pendingCreateWalletPromise?.resolve(resultCode == RESULT_OK) pendingCreateWalletPromise = null + } else if (requestCode == REQUEST_CODE_RESUME_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) + } } else if (requestCode == REQUEST_CODE_PUSH_TOKENIZE) { val localPromise = pendingPushTokenizePromise pendingPushTokenizePromise = null @@ -377,7 +399,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) : tokenServiceProvider, displayName, cardNetwork, - REQUEST_CODE_PUSH_TOKENIZE + REQUEST_CODE_RESUME_TOKENIZE ) } catch (e: java.lang.Exception) { pendingPushTokenizePromise = null From 6c40f8836723e917298e320152dc8008df2c50a1 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 8 Oct 2026 15:12:52 +0200 Subject: [PATCH 13/26] fix: type onCardActivated tokenId as nullable --- README.md | 2 +- src/NativeWallet.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 113fa513..7b879530 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ The library offers functions for seamless integration and use of the Apple Walle | **onPaymentCredentialsRequestPayload** | Payload received when Google Wallet requests payment credentials during UAPP provisioning. [Learn more β†’](./docs/UAPP.md) | `requestId: string`,
`serverSessionId: string`,
`walletId: string`,
`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` | diff --git a/src/NativeWallet.ts b/src/NativeWallet.ts index f06dd397..5c1e507d 100644 --- a/src/NativeWallet.ts +++ b/src/NativeWallet.ts @@ -46,7 +46,7 @@ type IOSCardData = { }; type onCardActivatedPayload = { - tokenId: string; + tokenId: string | null; status: 'activated' | 'canceled'; }; From 156aa2efea50f546b291c3c1ae20f02104a7664a Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Fri, 9 Oct 2026 13:53:55 +0200 Subject: [PATCH 14/26] fix: settle UAPP tokenize result without PushTokenizeResult like the legacy flow --- .../java/com/expensify/wallet/WalletModule.kt | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index 1baa333f..4eb6a8cb 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -132,29 +132,33 @@ class WalletModule internal constructor(context: ReactApplicationContext) : return } - if (data != null) { - val result = - IntentCompat.getParcelableExtra( - data, TapAndPay.EXTRA_PUSH_TOKENIZE_RESULT, PushTokenizeResult::class.java - ) - - if (result != null) { - val isSavedToCloud = result.cardResult - val successfulOutcome = result.tokenizationOutcomes.firstOrNull { it.tokenResult } - - if (isSavedToCloud || successfulOutcome != null) { - val tokenId = successfulOutcome?.issuerTokenId + val result = data?.let { + IntentCompat.getParcelableExtra( + it, TapAndPay.EXTRA_PUSH_TOKENIZE_RESULT, PushTokenizeResult::class.java + ) + } - sendEvent(context, OnCardActivatedEvent.NAME, OnCardActivatedEvent("activated", tokenId).toMap()) - localPromise?.resolve(TokenizationStatus.SUCCESS.code) - } else { - val errorMsg = "Card not saved. Status: ${result.cardStatus}" - localPromise?.reject(E_OPERATION_FAILED, errorMsg) - } + if (result == null) { + if (resultCode == RESULT_OK) { + sendEvent(context, OnCardActivatedEvent.NAME, OnCardActivatedEvent("activated", null).toMap()) + localPromise?.resolve(TokenizationStatus.SUCCESS.code) } else { - // Data intent is null (rare but possible failure case). Report to Google if you observe this. - localPromise?.reject(E_OPERATION_FAILED, "Unexpected error.") + localPromise?.resolve(TokenizationStatus.ERROR.code) } + return + } + + val isSavedToCloud = result.cardResult + val successfulOutcome = result.tokenizationOutcomes.firstOrNull { it.tokenResult } + + if (isSavedToCloud || successfulOutcome != null) { + val tokenId = successfulOutcome?.issuerTokenId + + sendEvent(context, OnCardActivatedEvent.NAME, OnCardActivatedEvent("activated", tokenId).toMap()) + localPromise?.resolve(TokenizationStatus.SUCCESS.code) + } else { + val errorMsg = "Card not saved. Status: ${result.cardStatus}" + localPromise?.reject(E_OPERATION_FAILED, errorMsg) } } } From 472ee270de460c2303bbb661959485d2a5bcaee3 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Fri, 9 Oct 2026 14:14:32 +0200 Subject: [PATCH 15/26] docs: fix UAPP acronym expansion --- README.md | 8 ++++---- docs/UAPP.md | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7b879530..8a6c9e22 100644 --- a/README.md +++ b/README.md @@ -5,7 +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 (User Account Payment Provisioning) support for enhanced security +- πŸ” UAPP (Unified Android Push Provisioning) support for enhanced security - πŸŽ‰ Expo support (expo-plugin) ## Getting started @@ -158,7 +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 (User Account Payment Provisioning) flow. This is generated by your backend using Google's encryption keys during the provisioning process. [Learn more about UAPP β†’](./docs/UAPP.md) +- **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. @@ -307,9 +307,9 @@ 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 (User Account Payment Provisioning) +# UAPP (Unified Android Push Provisioning) -For Android applications that require enhanced security for card provisioning, this library supports UAPP (User Account Payment Provisioning) flow with Google Wallet. +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)** diff --git a/docs/UAPP.md b/docs/UAPP.md index 1c771827..15b63709 100644 --- a/docs/UAPP.md +++ b/docs/UAPP.md @@ -1,8 +1,8 @@ -# UAPP (User Account Payment Provisioning) for Android +# UAPP (Unified Android Push Provisioning) for Android ## What is UAPP? -UAPP (User Account Payment 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. +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. @@ -106,7 +106,7 @@ Response object containing encrypted payment credentials for UAPP flow: 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 (User Account Payment Provisioning) flow. This is generated by your backend using Google's encryption keys during the provisioning process. +- **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 From f8e160cea75c207f13bc508b83a39d0b0b94e258 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Fri, 9 Oct 2026 14:29:38 +0200 Subject: [PATCH 16/26] chore: bump version to 0.2.0-0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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", From efa481074b30ca586af491710fc91981d2a17afd Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Fri, 9 Oct 2026 14:46:08 +0200 Subject: [PATCH 17/26] refactor: split onActivityResult into per-request handlers --- .../java/com/expensify/wallet/WalletModule.kt | 115 +++++++++--------- 1 file changed, 55 insertions(+), 60 deletions(-) diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index 4eb6a8cb..fbf015cc 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -95,75 +95,70 @@ class WalletModule internal constructor(context: ReactApplicationContext) : 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_RESUME_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) - } - } else if (requestCode == REQUEST_CODE_PUSH_TOKENIZE) { - val localPromise = pendingPushTokenizePromise - pendingPushTokenizePromise = null - if (resultCode == RESULT_CANCELED) { - sendEvent( - context, - OnCardActivatedEvent.NAME, - OnCardActivatedEvent("canceled", null).toMap() - ) - localPromise?.resolve(TokenizationStatus.CANCELED.code) - return + 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) + } + } - val result = data?.let { - IntentCompat.getParcelableExtra( - it, TapAndPay.EXTRA_PUSH_TOKENIZE_RESULT, PushTokenizeResult::class.java - ) - } + override fun onNewIntent(intent: Intent) {} + } - if (result == null) { - if (resultCode == RESULT_OK) { - sendEvent(context, OnCardActivatedEvent.NAME, OnCardActivatedEvent("activated", null).toMap()) - localPromise?.resolve(TokenizationStatus.SUCCESS.code) - } else { - localPromise?.resolve(TokenizationStatus.ERROR.code) - } - return - } + private fun takePendingTokenizePromise(): Promise? = + pendingPushTokenizePromise.also { pendingPushTokenizePromise = null } + + private fun sendCardActivatedEvent(status: String, tokenId: String?) { + sendEvent(reactApplicationContext, OnCardActivatedEvent.NAME, OnCardActivatedEvent(status, tokenId).toMap()) + } - val isSavedToCloud = result.cardResult - val successfulOutcome = result.tokenizationOutcomes.firstOrNull { it.tokenResult } + 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) + } + } - if (isSavedToCloud || successfulOutcome != null) { - val tokenId = successfulOutcome?.issuerTokenId + private fun handlePushTokenizeResult(resultCode: Int, data: Intent?) { + val promise = takePendingTokenizePromise() + if (resultCode == RESULT_CANCELED) { + sendCardActivatedEvent("canceled", null) + promise?.resolve(TokenizationStatus.CANCELED.code) + return + } - sendEvent(context, OnCardActivatedEvent.NAME, OnCardActivatedEvent("activated", tokenId).toMap()) - localPromise?.resolve(TokenizationStatus.SUCCESS.code) - } else { - val errorMsg = "Card not saved. Status: ${result.cardStatus}" - localPromise?.reject(E_OPERATION_FAILED, errorMsg) - } + val result = data?.let { + IntentCompat.getParcelableExtra(it, TapAndPay.EXTRA_PUSH_TOKENIZE_RESULT, PushTokenizeResult::class.java) + } + + if (result == null) { + if (resultCode == RESULT_OK) { + sendCardActivatedEvent("activated", null) + promise?.resolve(TokenizationStatus.SUCCESS.code) + } else { + promise?.resolve(TokenizationStatus.ERROR.code) } + return } - override fun onNewIntent(intent: Intent) {} + 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}") + } } @ReactMethod From 467d386306195b903693aae830e59710161c019e Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Fri, 9 Oct 2026 15:58:53 +0200 Subject: [PATCH 18/26] fix: mark onCardActivated tokenId as optional --- README.md | 2 +- src/NativeWallet.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8a6c9e22..57add64c 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ The library offers functions for seamless integration and use of the Apple Walle | **onPaymentCredentialsRequestPayload** | Payload received when Google Wallet requests payment credentials during UAPP provisioning. [Learn more β†’](./docs/UAPP.md) | `requestId: string`,
`serverSessionId: string`,
`walletId: string`,
`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 \| null`,
`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` | diff --git a/src/NativeWallet.ts b/src/NativeWallet.ts index 5c1e507d..12f8e099 100644 --- a/src/NativeWallet.ts +++ b/src/NativeWallet.ts @@ -46,7 +46,7 @@ type IOSCardData = { }; type onCardActivatedPayload = { - tokenId: string | null; + tokenId?: string | null; status: 'activated' | 'canceled'; }; From ac9c58488ca2ddc19e6cdd50764fd5e934bcf57f Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 12 Oct 2026 13:44:39 +0200 Subject: [PATCH 19/26] fix: report payment credentials failures with GeneratePaymentCredentialsException --- .../java/com/expensify/wallet/WalletModule.kt | 19 +++++++++++++------ docs/UAPP.md | 2 +- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index fbf015cc..3cb54f74 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -29,6 +29,7 @@ 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.PaymentCredentialsGenerator @@ -47,7 +48,6 @@ import java.util.UUID import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Future -import java.util.concurrent.TimeoutException class WalletModule internal constructor(context: ReactApplicationContext) : @@ -87,7 +87,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) : moduleScope.cancel() reactApplicationContext.removeActivityEventListener(cardListener) mainHandler.removeCallbacksAndMessages(null) - pendingProvisioningFutures.values.forEach { it.completeExceptionally(IllegalStateException("Wallet module invalidated")) } + pendingProvisioningFutures.values.forEach { it.completeExceptionally(paymentCredentialsError()) } pendingProvisioningFutures.clear() } @@ -295,6 +295,10 @@ class WalletModule internal constructor(context: ReactApplicationContext) : } } + // 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 { @@ -313,7 +317,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) : pendingProvisioningFutures[requestId] = future mainHandler.postDelayed({ pendingProvisioningFutures.remove(requestId) - ?.completeExceptionally(TimeoutException("Payment credentials were not provided in time")) + ?.completeExceptionally(paymentCredentialsError()) }, PAYMENT_CREDENTIALS_TIMEOUT_MS) val params = Arguments.createMap().apply { @@ -350,8 +354,11 @@ class WalletModule internal constructor(context: ReactApplicationContext) : 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)) + .setGoogleOpaquePaymentCard(googleOpc.toByteArray(Charsets.UTF_8)) .setOpaquePaymentCard(tspOpc.toByteArray(Charsets.UTF_8)) .build() @@ -359,7 +366,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) : promise.resolve(true) } catch (e: Exception) { - future.completeExceptionally(e) + future.completeExceptionally(paymentCredentialsError()) promise.reject(E_OPERATION_FAILED, e.message) } } @@ -371,7 +378,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) : promise.reject(E_OPERATION_FAILED, "Request ID not found or timed out") return } - future.completeExceptionally(Exception(errorMessage)) + future.completeExceptionally(paymentCredentialsError()) promise.resolve(true) } diff --git a/docs/UAPP.md b/docs/UAPP.md index 15b63709..b0dd3201 100644 --- a/docs/UAPP.md +++ b/docs/UAPP.md @@ -120,7 +120,7 @@ Initiates the Google Wallet card provisioning flow with UAPP support. **Returns:** `Promise` - Status of the tokenization process ('success', 'canceled', or 'error') -The handler is only called when Google Wallet requests a Google OPC. If it throws, the request is reported to Google Wallet as failed. If it does not resolve within 60 seconds, the request fails with a timeout. +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 From b219673a84f3948e86e7ef9af141e8b523fd444e Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 12 Oct 2026 13:44:41 +0200 Subject: [PATCH 20/26] feat: pass tokenRequestorId to the payment credentials handler --- README.md | 2 +- android/src/main/java/com/expensify/wallet/WalletModule.kt | 1 + docs/UAPP.md | 5 +++-- src/NativeWallet.ts | 1 + 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 57add64c..5d50856d 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ The library offers functions for seamless integration and use of the Apple Walle | **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`,
`opaquePaymentCard: 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 \| null`,
`status: 'activated' \| 'canceled'`
| diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index 3cb54f74..c7293407 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -324,6 +324,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) : putString("requestId", requestId) putString("serverSessionId", request.serverSessionId) putString("walletId", request.walletId) + putString("tokenRequestorId", request.tokenRequestorId) putString("opaquePaymentCard", cardData.opaquePaymentCard) } diff --git a/docs/UAPP.md b/docs/UAPP.md index b0dd3201..ca5748fe 100644 --- a/docs/UAPP.md +++ b/docs/UAPP.md @@ -62,10 +62,10 @@ const status = await addCardToGoogleWallet(cardData, handlePaymentCredentials); Your backend needs to: -1. Receive the `serverSessionId`, `opaquePaymentCard`, and `walletId` +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 +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 @@ -86,6 +86,7 @@ Payload received when Google Wallet requests payment credentials during UAPP pro 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 } ``` diff --git a/src/NativeWallet.ts b/src/NativeWallet.ts index 12f8e099..e85a0e22 100644 --- a/src/NativeWallet.ts +++ b/src/NativeWallet.ts @@ -54,6 +54,7 @@ type onPaymentCredentialsRequestPayload = { requestId: string; serverSessionId: string; walletId: string; + tokenRequestorId: string | null; opaquePaymentCard: string; }; From b4c059d6744c3e6a45a4d7d60a3e37b11f30f24a Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 12 Oct 2026 14:09:02 +0200 Subject: [PATCH 21/26] fix: read separate push tokenize result extras when PushTokenizeResult is missing --- .../java/com/expensify/wallet/WalletModule.kt | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index c7293407..dc9e959f 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -143,8 +143,27 @@ class WalletModule internal constructor(context: ReactApplicationContext) : } 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", null) + sendCardActivatedEvent("activated", data?.getStringExtra(TapAndPay.EXTRA_ISSUER_TOKEN_ID)) promise?.resolve(TokenizationStatus.SUCCESS.code) } else { promise?.resolve(TokenizationStatus.ERROR.code) @@ -152,12 +171,14 @@ class WalletModule internal constructor(context: ReactApplicationContext) : return } - val successfulOutcome = result.tokenizationOutcomes.firstOrNull { it.tokenResult } - if (result.cardResult || successfulOutcome != null) { - sendCardActivatedEvent("activated", successfulOutcome?.issuerTokenId) + 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. Status: ${result.cardStatus}") + promise?.reject(E_OPERATION_FAILED, "Card not saved") } } From b6561ad8635501f4638cd0ded5cee5031dc979cd Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 12 Oct 2026 14:09:25 +0200 Subject: [PATCH 22/26] chore: migrate deprecated TapAndPay calls to request variants --- .../java/com/expensify/wallet/WalletModule.kt | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index dc9e959f..d469eb9c 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -32,10 +32,13 @@ 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.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.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers @@ -227,7 +230,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) @@ -246,7 +249,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) @@ -421,14 +429,13 @@ class WalletModule internal constructor(context: ReactApplicationContext) : val displayName = getDisplayName(data, network) pendingPushTokenizePromise = promise - tapAndPayClient.tokenize( - activity, - tokenReferenceID, - tokenServiceProvider, - displayName, - cardNetwork, - REQUEST_CODE_RESUME_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) @@ -437,7 +444,7 @@ 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()) From 63b3cfa18ae57d72ea3bbb1368ee53e96d35ca7e Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 12 Oct 2026 14:10:35 +0200 Subject: [PATCH 23/26] feat: add hasEligibleTokenizationTarget --- README.md | 1 + .../java/com/expensify/wallet/WalletModule.kt | 20 ++++++++++++++++++ ios/RNWallet.mm | 9 ++++++++ src/NativeWallet.ts | 1 + src/index.tsx | 21 +++++++++++++++++++ 5 files changed, 52 insertions(+) diff --git a/README.md b/README.md index 5d50856d..a5c5088f 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ The library offers functions for seamless integration and use of the Apple Walle | **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` | βœ… | βœ… | +| **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[]` | ❌ | βœ… | diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index d469eb9c..79365dae 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -33,6 +33,7 @@ import com.google.android.gms.tapandpay.issuer.GeneratePaymentCredentialsExcepti 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 @@ -271,6 +272,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 diff --git a/ios/RNWallet.mm b/ios/RNWallet.mm index a17297c7..79d8b56d 100644 --- a/ios/RNWallet.mm +++ b/ios/RNWallet.mm @@ -135,6 +135,15 @@ - (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 diff --git a/src/NativeWallet.ts b/src/NativeWallet.ts index e85a0e22..e4100c74 100644 --- a/src/NativeWallet.ts +++ b/src/NativeWallet.ts @@ -90,6 +90,7 @@ export interface Spec extends TurboModule { 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; diff --git a/src/index.tsx b/src/index.tsx index 3e36ea17..ec3d62bb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -106,6 +106,26 @@ async function getCardStatusByIdentifier(identifier: string, tsp: string): Promi return getCardState(tokenState); } +/** + * 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, @@ -214,6 +234,7 @@ export { getSecureWalletInfo, getCardStatusBySuffix, getCardStatusByIdentifier, + hasEligibleTokenizationTarget, addCardToGoogleWallet, resumeAddCardToGoogleWallet, listTokens, From 5c8e613086da8c316d5d30d872a5c6b8bd1cdaa3 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 12 Oct 2026 14:27:29 +0200 Subject: [PATCH 24/26] docs: align payload comments in UAPP.md --- docs/UAPP.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/UAPP.md b/docs/UAPP.md index ca5748fe..7f5d99ec 100644 --- a/docs/UAPP.md +++ b/docs/UAPP.md @@ -30,7 +30,7 @@ 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', @@ -41,9 +41,9 @@ const handlePaymentCredentials = async (data) => { walletId, }), }); - + const credentials = await response.json(); - + // Return the credentials - library will automatically send them to Google Wallet return { opaquePaymentCard: credentials.tspOpc, @@ -83,11 +83,11 @@ Payload received when Google Wallet requests payment credentials during UAPP pro ```typescript { - requestId: string; // Unique identifier for this request - serverSessionId: string; // Session ID from Google (crucial for backend) - walletId: string; // Google Wallet ID + 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 + opaquePaymentCard: string; // Original OPC from TSP } ``` From 0da090c4d006b82b26a1fa4b04fcd85821938f7e Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 13 Oct 2026 13:10:21 +0200 Subject: [PATCH 25/26] refactor: use withTimeoutOrNull for payment credentials timeout --- .../java/com/expensify/wallet/WalletModule.kt | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index 79365dae..a46b4909 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -5,8 +5,6 @@ import android.app.Activity.RESULT_CANCELED import android.app.Activity.RESULT_OK import android.content.ActivityNotFoundException import android.content.Intent -import android.os.Handler -import android.os.Looper import androidx.core.content.IntentCompat import com.expensify.wallet.Utils.getAsyncResult import com.expensify.wallet.Utils.toCardData @@ -40,12 +38,14 @@ 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.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.withTimeoutOrNull import java.nio.charset.Charset import java.util.Locale import java.util.UUID @@ -78,8 +78,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) : private var pendingPushTokenizePromise: Promise? = null private val moduleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - private val pendingProvisioningFutures = ConcurrentHashMap>() - private val mainHandler = Handler(Looper.getMainLooper()) + private val pendingPaymentCredentials = ConcurrentHashMap>() override fun initialize() { super.initialize() @@ -90,9 +89,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) : super.invalidate() moduleScope.cancel() reactApplicationContext.removeActivityEventListener(cardListener) - mainHandler.removeCallbacksAndMessages(null) - pendingProvisioningFutures.values.forEach { it.completeExceptionally(paymentCredentialsError()) } - pendingProvisioningFutures.clear() + pendingPaymentCredentials.clear() } private val cardListener = object : ActivityEventListener { @@ -362,12 +359,21 @@ class WalletModule internal constructor(context: ReactApplicationContext) : } val future = CompletableFuture() + val credentials = CompletableDeferred() val requestId = UUID.randomUUID().toString() - pendingProvisioningFutures[requestId] = future - mainHandler.postDelayed({ - pendingProvisioningFutures.remove(requestId) - ?.completeExceptionally(paymentCredentialsError()) - }, PAYMENT_CREDENTIALS_TIMEOUT_MS) + pendingPaymentCredentials[requestId] = credentials + moduleScope.launch { + // 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) @@ -391,8 +397,8 @@ class WalletModule internal constructor(context: ReactApplicationContext) : @ReactMethod override fun AndroidProvidePaymentCredentials(requestId: String, responseData: ReadableMap, promise: Promise) { - val future = pendingProvisioningFutures.remove(requestId) - if (future == null) { + val credentials = pendingPaymentCredentials.remove(requestId) + if (credentials == null) { promise.reject(E_OPERATION_FAILED, "Request ID not found or timed out") return } @@ -412,23 +418,23 @@ class WalletModule internal constructor(context: ReactApplicationContext) : .setOpaquePaymentCard(tspOpc.toByteArray(Charsets.UTF_8)) .build() - future.complete(response) + credentials.complete(response) promise.resolve(true) } catch (e: Exception) { - future.completeExceptionally(paymentCredentialsError()) + credentials.completeExceptionally(paymentCredentialsError()) promise.reject(E_OPERATION_FAILED, e.message) } } @ReactMethod override fun AndroidRejectPaymentCredentials(requestId: String, errorMessage: String, promise: Promise) { - val future = pendingProvisioningFutures.remove(requestId) - if (future == null) { + val credentials = pendingPaymentCredentials.remove(requestId) + if (credentials == null) { promise.reject(E_OPERATION_FAILED, "Request ID not found or timed out") return } - future.completeExceptionally(paymentCredentialsError()) + credentials.completeExceptionally(paymentCredentialsError()) promise.resolve(true) } From d3208ec5c9787d3d0ba892d9d0f9d81cb0ce4f8b Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 13 Oct 2026 14:54:18 +0200 Subject: [PATCH 26/26] fix: start payment credentials coroutine undispatched --- android/src/main/java/com/expensify/wallet/WalletModule.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index a46b4909..a77dee0a 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -40,6 +40,7 @@ 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 @@ -362,7 +363,7 @@ class WalletModule internal constructor(context: ReactApplicationContext) : val credentials = CompletableDeferred() val requestId = UUID.randomUUID().toString() pendingPaymentCredentials[requestId] = credentials - moduleScope.launch { + 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() }