From 229f4c6899fe39cadd4bc05fc6ce7901d068a431 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Mon, 12 Oct 2026 18:48:48 -0300 Subject: [PATCH 1/3] 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, }; From 45dea91f40b7181590af01d7cc4ea4867e0247e9 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Tue, 27 Oct 2026 17:41:23 -0300 Subject: [PATCH 2/3] fix: look up the Watch pass when isRemote is omitted A missing flag now reaches the iPhone-then-Watch lookup instead of being treated as the iPhone. --- README.md | 2 +- ios/RNWallet.mm | 22 +++++++++++++--------- src/NativeWallet.ts | 2 +- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index dfd06a5d..88c0bf96 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ The library offers functions for seamless integration and use of the Apple Walle | **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` | -| **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` | +| **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. Set `isRemote` to `true` for a paired Apple Watch pass or `false` for the iPhone pass when the same card is on both. Omit it to look up the iPhone pass first and then the Watch. | `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` | diff --git a/ios/RNWallet.mm b/ios/RNWallet.mm index 19a3d11e..0ec4e83d 100644 --- a/ios/RNWallet.mm +++ b/ios/RNWallet.mm @@ -140,24 +140,28 @@ - (instancetype)init { reject:(RCTPromiseRejectBlock)reject) { @try { - NSDictionary *passDataDict = nil; + NSMutableDictionary *passDataDict = nil; #ifdef RCT_NEW_ARCH_ENABLED - passDataDict = @{ + passDataDict = [@{ @"passTypeIdentifier": [self safeString:passData.passTypeIdentifier()], @"serialNumber": [self safeString:passData.serialNumber()], @"activationData": [self safeString:passData.activationData()], - @"isRemote": @(passData.isRemote()), - }; + } mutableCopy]; + std::optional isRemote = passData.isRemote(); + if (isRemote.has_value()) { + passDataDict[@"isRemote"] = @(isRemote.value()); + } #else - id rawIsRemote = passData[@"isRemote"]; - BOOL isRemote = [rawIsRemote isKindOfClass:[NSNumber class]] && [rawIsRemote boolValue]; - passDataDict = @{ + passDataDict = [@{ @"passTypeIdentifier": [self safeString:passData[@"passTypeIdentifier"]], @"serialNumber": [self safeString:passData[@"serialNumber"]], @"activationData": [self safeString:passData[@"activationData"]], - @"isRemote": @(isRemote), - }; + } mutableCopy]; + id rawIsRemote = passData[@"isRemote"]; + if ([rawIsRemote isKindOfClass:[NSNumber class]]) { + passDataDict[@"isRemote"] = rawIsRemote; + } #endif [self->walletManager activateAppleWalletPassWithPassData:passDataDict completion:^(OperationResult result, NSDictionary* data) { diff --git a/src/NativeWallet.ts b/src/NativeWallet.ts index 37e3440e..442094e4 100644 --- a/src/NativeWallet.ts +++ b/src/NativeWallet.ts @@ -75,7 +75,7 @@ type IOSPassActivationData = { passTypeIdentifier: string; serialNumber: string; activationData: string; - isRemote: boolean; + isRemote?: boolean; }; type IOSWalletPassNative = { From 3a35bdd99e77254038ab610cb4dd85254af46444 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Thu, 29 Oct 2026 09:48:33 -0300 Subject: [PATCH 3/3] fix: share one wallet response handler Activation resolves through handleWalletResponse, the public pass type lives with the other public types, and listAppleWalletPasses only maps activationState. --- README.md | 2 +- ios/RNWallet.mm | 23 +++++------------------ src/NativeWallet.ts | 10 ++++++++++ src/index.tsx | 15 ++------------- 4 files changed, 18 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index be79374f..40336594 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ The library offers functions for seamless integration and use of the Apple Walle | **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. When `IOSCardData.primaryAccountIdentifier` is set, the device picker only lists devices that do not already have the card. If PassKit cannot present the flow because the card is already on every device, the call fails with a clear error. | `data`: `IOSCardData`,
`issuerEncrypt-`
`PayloadCallback: IOSIssuerCallback` | `void` | ✅ | ❌ | | **canAddCardToAppleWallet** | Checks whether the card can still be added to the iPhone or a paired Apple Watch. Pass `lastDigits` to use the same id lookup as `addCardToAppleWallet`. Returns `false` on Android. | `primaryAccountIdentifier: string`,
`lastDigits?: string` | `boolean` | ✅ | ✅ | -| **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` | ✅ | ❌ | +| **activateAppleWalletPass** | Activates an existing Secure Element pass in Apple Wallet using activation data from your issuer backend. Resolves `true` when the pass is already active. For `requireActivation`, returns Apple's result, which can be `false`. Rejects for any other state (`pending`, `suspended`), a missing pass, unavailable activation, or invalid or non-base64 activation data. | `data`: `IOSPassActivationData` | `boolean` | ✅ | ❌ | ## Data Types diff --git a/ios/RNWallet.mm b/ios/RNWallet.mm index a5b8e4d0..a4d43022 100644 --- a/ios/RNWallet.mm +++ b/ios/RNWallet.mm @@ -70,7 +70,7 @@ - (instancetype)init { dispatch_async(dispatch_get_main_queue(), ^{ [self->walletManager IOSPresentAddPaymentPassViewWithCardData:cardDataDict completion:^(OperationResult result, NSDictionary* data) { - [self handleWalletResponse:result data:data completedBlock:resolve errorPrefix:@"present_payment_pass_view_failed" defaultErrorMessage:@"Failed to present the payment pass view" rejecter:reject]; + [self handleWalletResponse:result data:data resolveValue:data completedBlock:resolve errorPrefix:@"present_payment_pass_view_failed" defaultErrorMessage:@"Failed to present the payment pass view" rejecter:reject]; }]; }); } @catch (NSException *exception) { @@ -106,7 +106,7 @@ - (instancetype)init { dispatch_async(dispatch_get_main_queue(), ^{ [self->walletManager IOSHandleAddPaymentPassResponseWithPayload:payloadDict completion:^(OperationResult result, NSDictionary* data) { - [self handleWalletResponse:result data:data completedBlock:resolve errorPrefix:@"add_card_failed" defaultErrorMessage:@"Failed to add the card to the wallet" rejecter:reject]; + [self handleWalletResponse:result data:data resolveValue:data completedBlock:resolve errorPrefix:@"add_card_failed" defaultErrorMessage:@"Failed to add the card to the wallet" rejecter:reject]; }]; }); } @catch (NSException *exception) { @@ -173,7 +173,7 @@ - (instancetype)init { #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]; + [self handleWalletResponse:result data:data resolveValue:@([data[@"success"] boolValue]) 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]; @@ -259,26 +259,13 @@ - (void)rejectWithErrorType:(NSString *)type - (void)handleWalletResponse:(OperationResult)result data:(NSDictionary *)data + resolveValue:(id)resolveValue completedBlock:(RCTPromiseResolveBlock)resolve errorPrefix:(NSString *)errorPrefix defaultErrorMessage:(NSString *)defaultErrorMsg rejecter:(RCTPromiseRejectBlock)reject { if (result < 3) { - resolve(data); - } else { - NSString *errorMessage = data[@"errorMessage"] ?: defaultErrorMsg ?: @"Operation failed"; - [self rejectWithErrorType:errorPrefix code:1001 description:errorMessage rejecter:reject]; - } -} - -- (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])); + resolve(resolveValue); } else { NSString *errorMessage = data[@"errorMessage"] ?: defaultErrorMsg ?: @"Operation failed"; [self rejectWithErrorType:errorPrefix code:1001 description:errorMessage rejecter:reject]; diff --git a/src/NativeWallet.ts b/src/NativeWallet.ts index ecbd8d27..9522e9f6 100644 --- a/src/NativeWallet.ts +++ b/src/NativeWallet.ts @@ -91,6 +91,15 @@ type IOSWalletPassNative = { isRemote: boolean; }; +type IOSWalletPass = { + passTypeIdentifier: string; + serialNumber: string; + primaryAccountIdentifier?: string; + lastDigits?: string; + activationState: CardStatus; + isRemote: boolean; +}; + type TokenizationStatus = 'canceled' | 'success' | 'error'; type TokenInfo = { @@ -154,6 +163,7 @@ export type { IOSDeviceType, IOSEncryptPayload, IOSPassActivationData, + IOSWalletPass, IOSWalletPassNative, TokenizationStatus, TokenInfo, diff --git a/src/index.tsx b/src/index.tsx index 901a3786..a08b373f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -15,7 +15,7 @@ import type { onPaymentCredentialsRequestPayload, IOSAddPaymentPassData, IOSPassActivationData, - IOSWalletPassNative, + IOSWalletPass, IOSDeviceType, TokenInfo, AndroidPaymentCredentialsResponse, @@ -23,15 +23,6 @@ import type { 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`)); } @@ -227,10 +218,8 @@ async function listAppleWalletPasses(): Promise { } const passes = await Wallet.listAppleWalletPasses(); - return passes.map((pass: IOSWalletPassNative) => ({ + return passes.map((pass) => ({ ...pass, - primaryAccountIdentifier: pass.primaryAccountIdentifier || undefined, - lastDigits: pass.lastDigits || undefined, activationState: getCardState(pass.activationState), })); }