diff --git a/README.md b/README.md index 1ec8b47..99df19f 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,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 five 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 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. ## Functions @@ -134,6 +134,8 @@ The library offers five functions for seamless integration and use of the Apple | **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` | ❌ | ✅ | +| **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` | ✅ | ❌ | @@ -143,11 +145,13 @@ The library offers five functions for seamless integration and use of the Apple |------|-------------|--------| | **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` | +| **AndroidResumeCardData** | Simplified data structure for resuming card addition to Google Wallet using existing token reference ID. | `network: string`,
`tokenReferenceID: string`,
`cardHolderName?: string`,
`lastDigits?: 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'`
| | **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` | ## Card Status diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index b714631..368f31f 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -188,13 +188,14 @@ class WalletModule internal constructor(context: ReactApplicationContext) : val cardData = data.toCardData() ?: return promise.reject(E_INVALID_DATA, "Insufficient data") val cardNetwork = getCardNetwork(cardData.network) val tokenServiceProvider = getTokenServiceProvider(cardData.network) + val displayName = getDisplayName(data, cardData.network) pendingPushTokenizePromise = promise val pushTokenizeRequest = PushTokenizeRequest.Builder() .setOpaquePaymentCard(cardData.opaquePaymentCard.toByteArray(Charset.forName("UTF-8"))) .setNetwork(cardNetwork) .setTokenServiceProvider(tokenServiceProvider) - .setDisplayName(cardData.cardHolderName) + .setDisplayName(displayName) .setLastDigits(cardData.lastDigits) .setUserAddress(cardData.userAddress) .build() @@ -207,6 +208,59 @@ class WalletModule internal constructor(context: ReactApplicationContext) : } } + @ReactMethod + override fun resumeAddCardToGoogleWallet(data: ReadableMap, promise: Promise) { + try { + val tokenReferenceID = data.getString("tokenReferenceID") + ?: return promise.reject(E_INVALID_DATA, "Missing tokenReferenceID") + + val network = data.getString("network") + ?: return promise.reject(E_INVALID_DATA, "Missing network") + + val cardNetwork = getCardNetwork(network) + val tokenServiceProvider = getTokenServiceProvider(network) + val displayName = getDisplayName(data, network) + pendingPushTokenizePromise = promise + + tapAndPayClient.tokenize( + activity, + tokenReferenceID, + tokenServiceProvider, + displayName, + cardNetwork, + REQUEST_CODE_PUSH_TOKENIZE + ) + } catch (e: java.lang.Exception) { + promise.reject(e) + } + } + + @ReactMethod + override fun listTokens(promise: Promise) { + tapAndPayClient.listTokens() + .addOnCompleteListener { task -> + if (!task.isSuccessful || task.result == null) { + promise.resolve(Arguments.createArray()) + return@addOnCompleteListener + } + + val tokensArray = Arguments.createArray() + task.result.forEach { tokenInfo -> + val tokenData = Arguments.createMap().apply { + putString("identifier", tokenInfo.issuerTokenId) + putString("lastDigits", tokenInfo.fpanLastFour) + putInt("tokenState", tokenInfo.tokenState) + } + tokensArray.pushMap(tokenData) + } + + promise.resolve(tokensArray) + } + .addOnFailureListener { e -> + promise.reject(E_OPERATION_FAILED, "listTokens: ${e.localizedMessage}") + } + } + private fun getWalletId(promise: Promise) { tapAndPayClient.activeWalletId.addOnCompleteListener { task -> if (task.isSuccessful) { @@ -272,6 +326,20 @@ class WalletModule internal constructor(context: ReactApplicationContext) : getHardwareId(promise) } + private fun getDisplayName(data: ReadableMap, network: String): String { + 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" + } + private fun sendEvent(reactContext: ReactContext, eventName: String, params: WritableMap?) { reactContext .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) diff --git a/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java b/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java index 4221a52..999f0cb 100644 --- a/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java +++ b/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java @@ -37,6 +37,10 @@ public NativeWalletSpec(ReactApplicationContext reactContext) { @DoNotStrip public abstract void checkWalletAvailability(Promise promise); + @ReactMethod + @DoNotStrip + public abstract void ensureGoogleWalletInitialized(Promise promise); + @ReactMethod @DoNotStrip public abstract void getSecureWalletInfo(Promise promise); @@ -53,6 +57,14 @@ public NativeWalletSpec(ReactApplicationContext reactContext) { @DoNotStrip public abstract void addCardToGoogleWallet(ReadableMap cardData, Promise promise); + @ReactMethod + @DoNotStrip + public abstract void resumeAddCardToGoogleWallet(ReadableMap cardData, Promise promise); + + @ReactMethod + @DoNotStrip + public abstract void listTokens(Promise promise); + @ReactMethod @DoNotStrip public abstract void IOSPresentAddPaymentPassView(ReadableMap cardData, Promise promise); diff --git a/example/src/App.tsx b/example/src/App.tsx index ae47a63..83f30a7 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -58,7 +58,7 @@ export default function App() { }, []); const handleAddCardToWallet = useCallback(() => { - addCardToWallet() + addCardToWallet(cardStatus) .then(status => { setAddCardStatus(status); }) @@ -66,7 +66,7 @@ export default function App() { console.error(e); setAddCardStatus('failed'); }); - }, []); + }, [cardStatus]); const walletSecureInfo = useMemo( () => getWalletInfoTextValue(walletData), diff --git a/example/src/CONST.ts b/example/src/CONST.ts index ffb1702..34bb1a7 100644 --- a/example/src/CONST.ts +++ b/example/src/CONST.ts @@ -3,6 +3,7 @@ import type { UserAddress, IOSEncryptPayload, IOSCardData, + AndroidResumeCardData, } from '../../src/NativeWallet'; const dummyAddress: UserAddress = { @@ -24,6 +25,13 @@ const AndroidDummyCardData: AndroidCardData = { userAddress: dummyAddress, }; +const AndroidDummyResumeCardData: AndroidResumeCardData = { + network: 'VISA', + cardHolderName: 'John Doe', + lastDigits: '4321', + tokenReferenceID: '', +}; + const IOSDummyCardData: IOSCardData = { network: 'VISA', cardHolderName: 'John Doe', @@ -38,4 +46,4 @@ const IOSDummyEncryptPayload: IOSEncryptPayload = { ephemeralPublicKey: 'ZXBoZW1lcmFsUHVibGljS2V5MTIz', }; -export {AndroidDummyCardData, IOSDummyCardData, IOSDummyEncryptPayload}; +export {AndroidDummyCardData, AndroidDummyResumeCardData, IOSDummyCardData, IOSDummyEncryptPayload}; diff --git a/example/src/walletUtils.ts b/example/src/walletUtils.ts index a043761..e0ce8e1 100644 --- a/example/src/walletUtils.ts +++ b/example/src/walletUtils.ts @@ -1,6 +1,9 @@ import { addCardToAppleWallet, addCardToGoogleWallet, + listTokens, + resumeAddCardToGoogleWallet, + type CardStatus, } from '@expensify/react-native-wallet'; import * as CONST from './CONST'; import {Platform} from 'react-native'; @@ -15,8 +18,26 @@ function issuerEncryptPayloadCallback( return Promise.resolve(CONST.IOSDummyEncryptPayload); } -async function addCardToWallet() { +async function addCardToWallet(cardStatus?: CardStatus) { if (Platform.OS === 'android') { + if (cardStatus === 'requireActivation') { + const tokens = await listTokens(); + const existingToken = tokens.find( + token => + token.lastDigits === CONST.AndroidDummyResumeCardData.lastDigits, + ); + + if (!existingToken) { + throw new Error( + `Token not found for card ending with ${CONST.AndroidDummyResumeCardData.lastDigits}`, + ); + } + + return await resumeAddCardToGoogleWallet({ + ...CONST.AndroidDummyResumeCardData, + tokenReferenceID: existingToken.identifier, + }); + } return addCardToGoogleWallet(CONST.AndroidDummyCardData); } else { return addCardToAppleWallet( diff --git a/src/NativeWallet.ts b/src/NativeWallet.ts index ae9bd7f..cf8a47d 100644 --- a/src/NativeWallet.ts +++ b/src/NativeWallet.ts @@ -29,6 +29,13 @@ type AndroidCardData = { userAddress: UserAddress; }; +type AndroidResumeCardData = { + network: string; + tokenReferenceID: string; + cardHolderName?: string; + lastDigits?: string; +}; + type IOSCardData = { network: string; cardHolderName: string; @@ -56,6 +63,12 @@ type IOSEncryptPayload = { type TokenizationStatus = 'canceled' | 'success' | 'error'; +type TokenInfo = { + identifier: string; + lastDigits: string; + tokenState: number; +}; + export interface Spec extends TurboModule { checkWalletAvailability(): Promise; ensureGoogleWalletInitialized(): Promise; @@ -63,6 +76,8 @@ export interface Spec extends TurboModule { getCardStatusBySuffix(last4Digits: string): Promise; getCardStatusByIdentifier(identifier: string, tsp: string): Promise; addCardToGoogleWallet(cardData: AndroidCardData): Promise; + resumeAddCardToGoogleWallet(cardData: AndroidResumeCardData): Promise; + listTokens(): Promise; IOSPresentAddPaymentPassView(cardData: IOSCardData): Promise; IOSHandleAddPaymentPassResponse(payload: IOSEncryptPayload): Promise; addListener: (eventType: string) => void; @@ -84,4 +99,17 @@ try { } export default Wallet; export {PACKAGE_NAME}; -export type {AndroidCardData, IOSCardData, AndroidWalletData, CardStatus, UserAddress, onCardActivatedPayload, Platform, IOSAddPaymentPassData, IOSEncryptPayload, TokenizationStatus}; +export type { + AndroidCardData, + AndroidResumeCardData, + IOSCardData, + AndroidWalletData, + CardStatus, + UserAddress, + onCardActivatedPayload, + Platform, + IOSAddPaymentPassData, + IOSEncryptPayload, + TokenizationStatus, + TokenInfo, +}; diff --git a/src/index.tsx b/src/index.tsx index 260e17e..1f94756 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,7 +2,18 @@ import {NativeEventEmitter, Platform} from 'react-native'; import type {EmitterSubscription} from 'react-native'; import Wallet, {PACKAGE_NAME} from './NativeWallet'; -import type {TokenizationStatus, AndroidCardData, CardStatus, IOSCardData, IOSEncryptPayload, AndroidWalletData, onCardActivatedPayload, IOSAddPaymentPassData} from './NativeWallet'; +import type { + TokenizationStatus, + AndroidCardData, + AndroidResumeCardData, + CardStatus, + IOSCardData, + IOSEncryptPayload, + AndroidWalletData, + onCardActivatedPayload, + IOSAddPaymentPassData, + TokenInfo, +} from './NativeWallet'; import {getCardState, getTokenizationStatus} from './utils'; import AddToWalletButton from './AddToWalletButton'; @@ -83,6 +94,37 @@ async function addCardToGoogleWallet(cardData: AndroidCardData): Promise { + if (Platform.OS === 'ios') { + throw new Error('resumeAddCardToGoogleWallet is not available on iOS'); + } + + if (!Wallet) { + return getModuleLinkingRejection(); + } + const isWalletInitialized = await Wallet.ensureGoogleWalletInitialized(); + if (!isWalletInitialized) { + throw new Error('Wallet could not be initialized'); + } + const tokenizationStatus = await Wallet.resumeAddCardToGoogleWallet(cardData); + return getTokenizationStatus(tokenizationStatus); +} + +async function listTokens(): Promise { + if (Platform.OS === 'ios') { + return Promise.resolve([]); + } + + if (!Wallet) { + return getModuleLinkingRejection(); + } + const isWalletInitialized = await Wallet.ensureGoogleWalletInitialized(); + if (!isWalletInitialized) { + throw new Error('Wallet could not be initialized'); + } + return Wallet.listTokens(); +} + async function addCardToAppleWallet( cardData: IOSCardData, issuerEncryptPayloadCallback: (nonce: string, nonceSignature: string, certificate: string[]) => Promise, @@ -110,7 +152,7 @@ async function addCardToAppleWallet( return getTokenizationStatus(status); } -export type {AndroidCardData, AndroidWalletData, CardStatus, IOSEncryptPayload, IOSCardData, IOSAddPaymentPassData, onCardActivatedPayload, TokenizationStatus}; +export type {AndroidCardData, AndroidWalletData, CardStatus, IOSEncryptPayload, IOSCardData, IOSAddPaymentPassData, onCardActivatedPayload, TokenizationStatus, TokenInfo}; export { AddToWalletButton, checkWalletAvailability, @@ -118,6 +160,8 @@ export { getCardStatusBySuffix, getCardStatusByIdentifier, addCardToGoogleWallet, + resumeAddCardToGoogleWallet, + listTokens, addCardToAppleWallet, addListener, removeListener,