Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`,<br>`issuerEncrypt-`<br>`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
Expand All @@ -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`,<br> `status: 'activated' \| 'canceled'`<br> |
| **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`,<br>`activationData: string`,<br>`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`,<br>`serialNumber: string`,<br>`activationData: string`,<br>`isRemote: boolean` |
| **IOSWalletPass** | Information about an Apple Wallet Secure Element pass currently available on the device or a paired remote device. | `passTypeIdentifier: string`,<br>`serialNumber: string`,<br>`primaryAccountIdentifier?: string`,<br>`lastDigits?: string`,<br>`activationState: CardStatus`,<br>`isRemote: boolean` |
| **TokenInfo** | Information about a token stored in Google Wallet. | `identifier: string`,<br>`lastDigits: string`,<br>`tokenState: number` |

## Card Status

| Type | Possible Values |
|------|-----------------|
| **CardStatus** | `not found`, `active`, `requireAuthorization`, `pending`, `suspended`, `deactivated` |
| **CardStatus** | `not found`, `active`, `requireActivation`, `pending`, `suspended`, `deactivated` |

## Listeners

Expand Down
8 changes: 8 additions & 0 deletions android/src/main/java/com/expensify/wallet/WalletModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
59 changes: 59 additions & 0 deletions ios/RNWallet.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ?: @"";
}
Expand Down
144 changes: 144 additions & 0 deletions ios/WalletManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The iPhone-then-Watch fallback only runs when isRemote is nil, but it never is: TS requires it, the new arch always passes a bool, and the old arch maps missing to false. So a caller passing false for a Watch-only card gets "Secure Element pass not found". Could we make isRemote optional end to end (isRemote?: boolean in TS, nil from RNWallet.mm when missing) so the fallback actually runs?

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 {
Expand Down
20 changes: 20 additions & 0 deletions src/NativeWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -78,8 +94,10 @@ export interface Spec extends TurboModule {
addCardToGoogleWallet(cardData: AndroidCardData): Promise<number>;
resumeAddCardToGoogleWallet(cardData: AndroidResumeCardData): Promise<number>;
listTokens(): Promise<TokenInfo[]>;
listAppleWalletPasses(): Promise<IOSWalletPassNative[]>;
IOSPresentAddPaymentPassView(cardData: IOSCardData): Promise<IOSAddPaymentPassData>;
IOSHandleAddPaymentPassResponse(payload: IOSEncryptPayload): Promise<IOSAddPaymentPassData | null>;
activateAppleWalletPass(passData: IOSPassActivationData): Promise<boolean>;
addListener: (eventType: string) => void;
removeListeners: (count: number) => void;
}
Expand Down Expand Up @@ -110,6 +128,8 @@ export type {
Platform,
IOSAddPaymentPassData,
IOSEncryptPayload,
IOSPassActivationData,
IOSWalletPassNative,
TokenizationStatus,
TokenInfo,
};
Loading
Loading