diff --git a/README.md b/README.md index 79d334f..4033659 100644 --- a/README.md +++ b/README.md @@ -186,8 +186,10 @@ The library offers functions for seamless integration and use of the Apple Walle | **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[]` | ❌ | ✅ | +| **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. 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 @@ -205,6 +207,8 @@ The library offers functions for seamless integration and use of the Apple Walle | **IOSDeviceType** | Best-effort device the card is being provisioned to. PassKit does not report the selected device, so this is inferred from existing passes when `primaryAccountIdentifier` is set, using the same id lookup as `addCardToAppleWallet`. The value is computed when Apple asks for the encrypted payload, including when the user tries again. Passes on a paired Apple Watch are never counted as iPhone passes. `phone` means the card is already on the paired Apple Watch only. `watch` means it is already on the iPhone only. `unknown` means the card is on neither device, or no identifier was provided, so the user may still choose either device. Passes in the `deactivated` state count as not present. | `'phone' \| 'watch' \| 'unknown'` | | **IOSAddPaymentPassData** | Data returned when the user continues the provisioning flow. Apple supplies the nonce, its signature, and the certificates. The library adds `deviceType`; Apple does not return it. See `IOSDeviceType`. | `status: number`,
`nonce: string`,
`nonceSignature: string`,
`certificates: string[]`,
`deviceType?: string` | | **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. 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` | ### AndroidCardData Optional Fields @@ -232,7 +236,7 @@ This flag helps Google Wallet track cards that were initially added through the | 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 e2105a0..af84cf7 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -588,6 +588,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 canAddCardToAppleWallet(primaryAccountIdentifier: String, lastDigits: String, promise: Promise) { promise.resolve(false) } diff --git a/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java b/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java index 0d98ee1..07eb60e 100644 --- a/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java +++ b/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java @@ -73,6 +73,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); @@ -81,6 +85,8 @@ public NativeWalletSpec(ReactApplicationContext reactContext) { @DoNotStrip public abstract void IOSHandleAddPaymentPassResponse(ReadableMap payload, Promise promise); + public abstract void activateAppleWalletPass(ReadableMap passData, Promise promise); + @ReactMethod @DoNotStrip public abstract void canAddCardToAppleWallet(String primaryAccountIdentifier, String lastDigits, Promise promise); diff --git a/ios/RNWallet.mm b/ios/RNWallet.mm index 05a4722..a4d4302 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) { @@ -131,6 +131,55 @@ - (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 { + NSMutableDictionary *passDataDict = nil; + +#ifdef RCT_NEW_ARCH_ENABLED + passDataDict = [@{ + @"passTypeIdentifier": [self safeString:passData.passTypeIdentifier()], + @"serialNumber": [self safeString:passData.serialNumber()], + @"activationData": [self safeString:passData.activationData()], + } mutableCopy]; + std::optional isRemote = passData.isRemote(); + if (isRemote.has_value()) { + passDataDict[@"isRemote"] = @(isRemote.value()); + } +#else + passDataDict = [@{ + @"passTypeIdentifier": [self safeString:passData[@"passTypeIdentifier"]], + @"serialNumber": [self safeString:passData[@"serialNumber"]], + @"activationData": [self safeString:passData[@"activationData"]], + } mutableCopy]; + id rawIsRemote = passData[@"isRemote"]; + if ([rawIsRemote isKindOfClass:[NSNumber class]]) { + passDataDict[@"isRemote"] = rawIsRemote; + } +#endif + + [self->walletManager activateAppleWalletPassWithPassData:passDataDict completion:^(OperationResult result, NSDictionary* data) { + [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]; + } +} + RCT_REMAP_METHOD(canAddCardToAppleWallet, canAddCardToAppleWallet:(NSString *)primaryAccountIdentifier lastDigits:(NSString *)lastDigits @@ -210,12 +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); + resolve(resolveValue); } else { NSString *errorMessage = data[@"errorMessage"] ?: defaultErrorMsg ?: @"Operation failed"; [self rejectWithErrorType:errorPrefix code:1001 description:errorMessage rejecter:reject]; diff --git a/ios/WalletManager.swift b/ios/WalletManager.swift index 7080c60..6e5c8c7 100644 --- a/ios/WalletManager.swift +++ b/ios/WalletManager.swift @@ -228,6 +228,103 @@ open class WalletManager: UIViewController { } } + @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]) + } + } + @objc public func canAddCardToAppleWallet(primaryAccountIdentifier: NSString, lastDigits: NSString) -> Bool { let suffix = lastDigits as String let resolved = suffix.isEmpty @@ -300,6 +397,53 @@ open class WalletManager: UIViewController { 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 94d5787..9522e9f 100644 --- a/src/NativeWallet.ts +++ b/src/NativeWallet.ts @@ -75,6 +75,31 @@ 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 IOSWalletPass = { + passTypeIdentifier: string; + serialNumber: string; + primaryAccountIdentifier?: string; + lastDigits?: string; + activationState: CardStatus; + isRemote: boolean; +}; + type TokenizationStatus = 'canceled' | 'success' | 'error'; type TokenInfo = { @@ -100,8 +125,10 @@ export interface Spec extends TurboModule { AndroidRejectPaymentCredentials(requestId: string, errorMessage: string): Promise; resumeAddCardToGoogleWallet(cardData: AndroidResumeCardData): Promise; listTokens(): Promise; + listAppleWalletPasses(): Promise; IOSPresentAddPaymentPassView(cardData: IOSCardData): Promise; IOSHandleAddPaymentPassResponse(payload: IOSEncryptPayload): Promise; + activateAppleWalletPass(passData: IOSPassActivationData): Promise; canAddCardToAppleWallet(primaryAccountIdentifier: string, lastDigits: string): Promise; addListener: (eventType: string) => void; removeListeners: (count: number) => void; @@ -135,6 +162,9 @@ export type { IOSAddPaymentPassData, IOSDeviceType, IOSEncryptPayload, + IOSPassActivationData, + IOSWalletPass, + IOSWalletPassNative, TokenizationStatus, TokenInfo, AndroidPaymentCredentialsResponse, diff --git a/src/index.tsx b/src/index.tsx index de31170..a08b373 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,6 +14,8 @@ import type { onCardActivatedPayload, onPaymentCredentialsRequestPayload, IOSAddPaymentPassData, + IOSPassActivationData, + IOSWalletPass, IOSDeviceType, TokenInfo, AndroidPaymentCredentialsResponse, @@ -206,6 +208,22 @@ async function canAddCardToAppleWallet(primaryAccountIdentifier: string, lastDig return Wallet.canAddCardToAppleWallet(primaryAccountIdentifier, lastDigits ?? ''); } +async function listAppleWalletPasses(): Promise { + if (Platform.OS === 'android') { + return Promise.resolve([]); + } + + if (!Wallet) { + return getModuleLinkingRejection(); + } + + const passes = await Wallet.listAppleWalletPasses(); + return passes.map((pass) => ({ + ...pass, + activationState: getCardState(pass.activationState), + })); +} + async function addCardToAppleWallet( cardData: IOSCardData, issuerEncryptPayloadCallback: (nonce: string, nonceSignature: string, certificate: string[], deviceType: IOSDeviceType) => Promise, @@ -239,6 +257,18 @@ async function addCardToAppleWallet( return getTokenizationStatus(status); } +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, @@ -246,6 +276,8 @@ export type { IOSEncryptPayload, IOSCardData, IOSAddPaymentPassData, + IOSPassActivationData, + IOSWalletPass, IOSDeviceType, onCardActivatedPayload, onPaymentCredentialsRequestPayload, @@ -263,8 +295,10 @@ export { addCardToGoogleWallet, resumeAddCardToGoogleWallet, listTokens, + listAppleWalletPasses, canAddCardToAppleWallet, addCardToAppleWallet, + activateAppleWalletPass, addListener, removeListener, };