From 229f4c6899fe39cadd4bc05fc6ce7901d068a431 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Mon, 12 Oct 2026 18:48:48 -0300 Subject: [PATCH] feat: list and activate Apple Wallet passes Apps can read each pass's activation state on the iPhone and a paired Watch, then finish verification without leaving the app. --- README.md | 8 +- .../java/com/expensify/wallet/WalletModule.kt | 8 + .../expensify/wallet/NativeWalletSpec.java | 8 + ios/RNWallet.mm | 59 +++++++ ios/WalletManager.swift | 144 ++++++++++++++++++ src/NativeWallet.ts | 20 +++ src/index.tsx | 57 ++++++- 7 files changed, 301 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ee3b7fdc..93944041 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,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 ten 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 @@ -181,7 +181,9 @@ The library offers seven functions for seamless integration and use of the Apple | **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[]` | ❌ | ✅ | +| **listAppleWalletPasses** | Lists Apple Wallet Secure Element passes available to the app, including activation state and whether the pass is remote (for example on Apple Watch). Filter the result for `activationState === 'requireActivation'` or `activationState === 'pending'` to build a pending-cards UI. | None | `IOSWalletPass[]` | ✅ | ❌ | | **addCardToAppleWallet** | Initiates native Push Provisioning flow for adding a card to the Apple Wallet. | `data`: `IOSCardData`,
`issuerEncrypt-`
`PayloadCallback: IOSIssuerCallback` | `void` | ✅ | ❌ | +| **activateAppleWalletPass** | Activates an existing Secure Element pass in Apple Wallet using activation data from your issuer backend. Best used for passes in the `requireActivation` state. | `data`: `IOSPassActivationData` | `boolean` | ✅ | ❌ | ## Data Types @@ -196,13 +198,15 @@ The library offers seven functions for seamless integration and use of the Apple | **onCardActivatedPayload** | Data used by listener to notice when a card's status changes. | `tokenId: string`,
`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` | +| **IOSPassActivationData** | Identifies an existing Apple Wallet Secure Element pass and provides the activation payload returned by your issuer backend. `activationData` must be Base 64 encoded. | `passTypeIdentifier: string`,
`serialNumber: string`,
`activationData: string`,
`isRemote: boolean` | +| **IOSWalletPass** | Information about an Apple Wallet Secure Element pass currently available on the device or a paired remote device. | `passTypeIdentifier: string`,
`serialNumber: string`,
`primaryAccountIdentifier?: string`,
`lastDigits?: string`,
`activationState: CardStatus`,
`isRemote: boolean` | | **TokenInfo** | Information about a token stored in Google Wallet. | `identifier: string`,
`lastDigits: string`,
`tokenState: number` | ## Card Status | Type | Possible Values | |------|-----------------| -| **CardStatus** | `not found`, `active`, `requireAuthorization`, `pending`, `suspended`, `deactivated` | +| **CardStatus** | `not found`, `active`, `requireActivation`, `pending`, `suspended`, `deactivated` | ## Listeners diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index dfa1a398..366f86df 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -365,6 +365,14 @@ class WalletModule internal constructor(context: ReactApplicationContext) : // no-op } + override fun listAppleWalletPasses(promise: Promise) { + // no-op + } + + override fun activateAppleWalletPass(passData: ReadableMap?, promise: Promise?) { + // no-op + } + override fun addListener(eventType: String?) { // no-op } diff --git a/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java b/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java index 999f0cb8..551dd961 100644 --- a/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java +++ b/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java @@ -65,6 +65,10 @@ public NativeWalletSpec(ReactApplicationContext reactContext) { @DoNotStrip public abstract void listTokens(Promise promise); + @ReactMethod + @DoNotStrip + public abstract void listAppleWalletPasses(Promise promise); + @ReactMethod @DoNotStrip public abstract void IOSPresentAddPaymentPassView(ReadableMap cardData, Promise promise); @@ -73,6 +77,10 @@ public NativeWalletSpec(ReactApplicationContext reactContext) { @DoNotStrip public abstract void IOSHandleAddPaymentPassResponse(ReadableMap payload, Promise promise); + @ReactMethod + @DoNotStrip + public abstract void activateAppleWalletPass(ReadableMap passData, Promise promise); + @ReactMethod @DoNotStrip public abstract void addListener(String eventType); diff --git a/ios/RNWallet.mm b/ios/RNWallet.mm index 4981b78a..50903235 100644 --- a/ios/RNWallet.mm +++ b/ios/RNWallet.mm @@ -123,6 +123,51 @@ - (instancetype)init { resolve([walletManager getCardStatusByIdentifierWithIdentifier:identifier]); } +RCT_REMAP_METHOD(listAppleWalletPasses, + listAppleWalletPasses:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + resolve([walletManager listAppleWalletPasses]); +} + +RCT_REMAP_METHOD(activateAppleWalletPass, +#ifdef RCT_NEW_ARCH_ENABLED + activateAppleWalletPass:(JS::NativeWallet::IOSPassActivationData &)passData +#else + activateAppleWalletPass:(NSDictionary *)passData +#endif + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + @try { + NSDictionary *passDataDict = nil; + +#ifdef RCT_NEW_ARCH_ENABLED + passDataDict = @{ + @"passTypeIdentifier": [self safeString:passData.passTypeIdentifier()], + @"serialNumber": [self safeString:passData.serialNumber()], + @"activationData": [self safeString:passData.activationData()], + @"isRemote": @(passData.isRemote()), + }; +#else + id rawIsRemote = passData[@"isRemote"]; + BOOL isRemote = [rawIsRemote isKindOfClass:[NSNumber class]] && [rawIsRemote boolValue]; + passDataDict = @{ + @"passTypeIdentifier": [self safeString:passData[@"passTypeIdentifier"]], + @"serialNumber": [self safeString:passData[@"serialNumber"]], + @"activationData": [self safeString:passData[@"activationData"]], + @"isRemote": @(isRemote), + }; +#endif + + [self->walletManager activateAppleWalletPassWithPassData:passDataDict completion:^(OperationResult result, NSDictionary* data) { + [self handleBooleanWalletResponse:result data:data completedBlock:resolve errorPrefix:@"activate_card_failed" defaultErrorMessage:@"Failed to activate the Apple Wallet pass" rejecter:reject]; + }]; + } @catch (NSException *exception) { + [self rejectWithErrorType:@"activate_card_failed" code:500 description:exception.reason ?: @"An unexpected error occurred." rejecter:reject]; + } +} + - (void)addCardToGoogleWallet: #ifdef RCT_NEW_ARCH_ENABLED (JS::NativeWallet::AndroidCardData &)cardData @@ -175,6 +220,20 @@ - (void)handleWalletResponse:(OperationResult)result } } +- (void)handleBooleanWalletResponse:(OperationResult)result + data:(NSDictionary *)data + completedBlock:(RCTPromiseResolveBlock)resolve + errorPrefix:(NSString *)errorPrefix + defaultErrorMessage:(NSString *)defaultErrorMsg + rejecter:(RCTPromiseRejectBlock)reject { + if (result < 3) { + resolve(@([data[@"success"] boolValue])); + } else { + NSString *errorMessage = data[@"errorMessage"] ?: defaultErrorMsg ?: @"Operation failed"; + [self rejectWithErrorType:errorPrefix code:1001 description:errorMessage rejecter:reject]; + } +} + - (NSString *)safeString:(NSString *)value { return value ?: @""; } diff --git a/ios/WalletManager.swift b/ios/WalletManager.swift index 6b3e4c35..c289c7c3 100644 --- a/ios/WalletManager.swift +++ b/ios/WalletManager.swift @@ -212,10 +212,154 @@ open class WalletManager: UIViewController { return pass.primaryAccountIdentifier == identifier as String } } + + @objc public func listAppleWalletPasses() -> [NSDictionary] { + let localPasses = passLibrary.passes(of: .secureElement).compactMap { pass -> NSDictionary? in + guard pass.isRemotePass == false, + let secureElementPass = pass.secureElementPass else { + return nil + } + + return makeAppleWalletPassDictionary( + passTypeIdentifier: pass.passTypeIdentifier, + serialNumber: pass.serialNumber, + primaryAccountIdentifier: secureElementPass.primaryAccountIdentifier, + lastDigits: secureElementPass.primaryAccountNumberSuffix, + activationState: secureElementPass.passActivationState.rawValue, + isRemote: pass.isRemotePass + ) + } + + let remotePasses = passLibrary.remoteSecureElementPasses.map { pass in + makeAppleWalletPassDictionary( + passTypeIdentifier: pass.passTypeIdentifier, + serialNumber: pass.serialNumber, + primaryAccountIdentifier: pass.primaryAccountIdentifier, + lastDigits: pass.primaryAccountNumberSuffix, + activationState: pass.passActivationState.rawValue, + isRemote: true + ) + } + + return localPasses + remotePasses + } + + @objc public func activateAppleWalletPass(passData: NSDictionary, completion: @escaping CompletionHandler) { + guard passLibrary.isSecureElementPassActivationAvailable else { + completion(.error, [ + "errorMessage": "Secure Element pass activation is not available for this device" + ]) + return + } + + guard + let passTypeIdentifier = passData["passTypeIdentifier"] as? String, + !passTypeIdentifier.isEmpty, + let serialNumber = passData["serialNumber"] as? String, + !serialNumber.isEmpty, + let activationDataString = passData["activationData"] as? String, + !activationDataString.isEmpty + else { + completion(.error, [ + "errorMessage": "Invalid pass activation data" + ]) + return + } + + let isRemote = passData["isRemote"] as? Bool + + guard let activationData = Data(base64Encoded: activationDataString) else { + completion(.error, [ + "errorMessage": "activationData must be a valid base64 encoded string" + ]) + return + } + + guard let secureElementPass = findSecureElementPass( + passTypeIdentifier: passTypeIdentifier, + serialNumber: serialNumber, + isRemote: isRemote + ) else { + completion(.error, [ + "errorMessage": "Secure Element pass not found" + ]) + return + } + + if secureElementPass.passActivationState == .activated { + completion(.completed, ["success": true]) + return + } + + guard secureElementPass.passActivationState == .requiresActivation else { + completion(.error, [ + "errorMessage": "Secure Element pass is not eligible for activation. Current state: \(secureElementPass.passActivationState.rawValue)" + ]) + return + } + + passLibrary.activate(secureElementPass, activationData: activationData) { success, error in + if let error = error { + completion(.error, [ + "errorMessage": error.localizedDescription + ]) + return + } + + completion(.completed, ["success": success]) + } + } private func isPassKitAvailable() -> Bool { return PKAddPaymentPassViewController.canAddPaymentPass() } + + private func findSecureElementPass(passTypeIdentifier: String, serialNumber: String, isRemote: Bool?) -> PKSecureElementPass? { + if isRemote == true { + return passLibrary.remoteSecureElementPasses.first { pass in + pass.passTypeIdentifier == passTypeIdentifier && pass.serialNumber == serialNumber + } + } + + if let pass = passLibrary.pass(withPassTypeIdentifier: passTypeIdentifier, serialNumber: serialNumber), + pass.isRemotePass == false { + return pass.secureElementPass + } + + if isRemote == false { + return nil + } + + return passLibrary.remoteSecureElementPasses.first { pass in + pass.passTypeIdentifier == passTypeIdentifier && pass.serialNumber == serialNumber + } + } + + private func makeAppleWalletPassDictionary( + passTypeIdentifier: String, + serialNumber: String, + primaryAccountIdentifier: String?, + lastDigits: String?, + activationState: Int, + isRemote: Bool + ) -> NSDictionary { + let result = NSMutableDictionary(dictionary: [ + "passTypeIdentifier": passTypeIdentifier, + "serialNumber": serialNumber, + "activationState": activationState, + "isRemote": isRemote + ]) + + if let primaryAccountIdentifier = primaryAccountIdentifier, !primaryAccountIdentifier.isEmpty { + result["primaryAccountIdentifier"] = primaryAccountIdentifier + } + + if let lastDigits = lastDigits, !lastDigits.isEmpty { + result["lastDigits"] = lastDigits + } + + return result + } private func hideModal() { guard let enrollVC = addPassViewController else { diff --git a/src/NativeWallet.ts b/src/NativeWallet.ts index f15df6e9..0707c2c1 100644 --- a/src/NativeWallet.ts +++ b/src/NativeWallet.ts @@ -61,6 +61,22 @@ type IOSEncryptPayload = { ephemeralPublicKey: string; }; +type IOSPassActivationData = { + passTypeIdentifier: string; + serialNumber: string; + activationData: string; + isRemote: boolean; +}; + +type IOSWalletPassNative = { + passTypeIdentifier: string; + serialNumber: string; + primaryAccountIdentifier?: string; + lastDigits?: string; + activationState: number; + isRemote: boolean; +}; + type TokenizationStatus = 'canceled' | 'success' | 'error'; type TokenInfo = { @@ -78,8 +94,10 @@ export interface Spec extends TurboModule { addCardToGoogleWallet(cardData: AndroidCardData): Promise; resumeAddCardToGoogleWallet(cardData: AndroidResumeCardData): Promise; listTokens(): Promise; + listAppleWalletPasses(): Promise; IOSPresentAddPaymentPassView(cardData: IOSCardData): Promise; IOSHandleAddPaymentPassResponse(payload: IOSEncryptPayload): Promise; + activateAppleWalletPass(passData: IOSPassActivationData): Promise; addListener: (eventType: string) => void; removeListeners: (count: number) => void; } @@ -110,6 +128,8 @@ export type { Platform, IOSAddPaymentPassData, IOSEncryptPayload, + IOSPassActivationData, + IOSWalletPassNative, TokenizationStatus, TokenInfo, }; diff --git a/src/index.tsx b/src/index.tsx index d1801ffc..8268e34d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -12,11 +12,22 @@ import type { AndroidWalletData, onCardActivatedPayload, IOSAddPaymentPassData, + IOSPassActivationData, + IOSWalletPassNative, TokenInfo, } from './NativeWallet'; import {getCardState, getTokenizationStatus} from './utils'; import AddToWalletButton from './AddToWalletButton'; +type IOSWalletPass = { + passTypeIdentifier: string; + serialNumber: string; + primaryAccountIdentifier?: string; + lastDigits?: string; + activationState: CardStatus; + isRemote: boolean; +}; + function getModuleLinkingRejection() { return Promise.reject(new Error(`Failed to load Wallet module, make sure to link ${PACKAGE_NAME} correctly`)); } @@ -131,6 +142,24 @@ async function listTokens(): Promise { return Wallet.listTokens(); } +async function listAppleWalletPasses(): Promise { + if (Platform.OS === 'android') { + return Promise.resolve([]); + } + + if (!Wallet) { + return getModuleLinkingRejection(); + } + + const passes = await Wallet.listAppleWalletPasses(); + return passes.map((pass: IOSWalletPassNative) => ({ + ...pass, + primaryAccountIdentifier: pass.primaryAccountIdentifier || undefined, + lastDigits: pass.lastDigits || undefined, + activationState: getCardState(pass.activationState), + })); +} + async function addCardToAppleWallet( cardData: IOSCardData, issuerEncryptPayloadCallback: (nonce: string, nonceSignature: string, certificate: string[]) => Promise, @@ -164,7 +193,31 @@ async function addCardToAppleWallet( return getTokenizationStatus(status); } -export type {AndroidCardData, AndroidWalletData, CardStatus, IOSEncryptPayload, IOSCardData, IOSAddPaymentPassData, onCardActivatedPayload, TokenizationStatus, TokenInfo}; +async function activateAppleWalletPass(passData: IOSPassActivationData): Promise { + if (Platform.OS === 'android') { + throw new Error('activateAppleWalletPass is not available on Android'); + } + + if (!Wallet) { + return getModuleLinkingRejection(); + } + + return Wallet.activateAppleWalletPass(passData); +} + +export type { + AndroidCardData, + AndroidWalletData, + CardStatus, + IOSEncryptPayload, + IOSCardData, + IOSAddPaymentPassData, + IOSPassActivationData, + IOSWalletPass, + onCardActivatedPayload, + TokenizationStatus, + TokenInfo, +}; export { AddToWalletButton, checkWalletAvailability, @@ -174,7 +227,9 @@ export { addCardToGoogleWallet, resumeAddCardToGoogleWallet, listTokens, + listAppleWalletPasses, addCardToAppleWallet, + activateAppleWalletPass, addListener, removeListener, };