Skip to content
Merged
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,<br>`handlePaymentCredentials`: `(data: onPaymentCredentialsRequestPayload) => Promise<AndroidPaymentCredentialsResponse>` | `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`,<br>`issuerEncrypt-`<br>`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`,<br>`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

Expand All @@ -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`,<br>`nonce: string`,<br>`nonceSignature: string`,<br>`certificates: string[]`,<br>`deviceType?: string` |
| **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. 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`,<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` |

### AndroidCardData Optional Fields
Expand Down Expand Up @@ -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

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 @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
56 changes: 53 additions & 3 deletions ios/RNWallet.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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<bool> 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
Expand Down Expand Up @@ -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];
Expand Down
144 changes: 144 additions & 0 deletions ios/WalletManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Comment thread
JakubKorytko marked this conversation as resolved.
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
30 changes: 30 additions & 0 deletions src/NativeWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -100,8 +125,10 @@ export interface Spec extends TurboModule {
AndroidRejectPaymentCredentials(requestId: string, errorMessage: string): Promise<boolean>;
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>;
canAddCardToAppleWallet(primaryAccountIdentifier: string, lastDigits: string): Promise<boolean>;
addListener: (eventType: string) => void;
removeListeners: (count: number) => void;
Expand Down Expand Up @@ -135,6 +162,9 @@ export type {
IOSAddPaymentPassData,
IOSDeviceType,
IOSEncryptPayload,
IOSPassActivationData,
IOSWalletPass,
IOSWalletPassNative,
TokenizationStatus,
TokenInfo,
AndroidPaymentCredentialsResponse,
Expand Down
Loading
Loading