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
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ Here you can find data elements used in the library, essential to work with Goog
- **Certificates** - an array of NSData objects. Each object contains a DER encoded X.509 certificate, with the leaf first and root last. You must download the root CA to validate the entire chain.
- **Encrypted Pass Data**- an encrypted JSON file containing the sensitive information needed to add a card to Apple Pay (Base 64 encoded). [More details here.](https://developer.apple.com/documentation/passkit/pkaddpaymentpassrequest/encryptedpassdata)
- **Ephemeral Public Key** - a key used by elliptic curve cryptography (ECC) (Base 64 encoded).
- **Primary Account Identifier** - an opaque issuer id (also called the FPAN ID) that PassKit uses to recognize the same card across reissues and devices. Pass it in `IOSCardData` and to `canAddCardToAppleWallet`. `getCardStatusByIdentifier` matches this value exactly and does not fall back. `addCardToAppleWallet`, the inferred `deviceType`, and `canAddCardToAppleWallet` when you also pass `lastDigits` share one lookup: an exact id match wins, and otherwise the library uses the id of the only pass that is not `deactivated` and has the same last digits. A different card that is still active and ends in the same digits can be picked. Pass the id PassKit stored on the pass (the `primaryAccountIdentifier` from `listAppleWalletPasses`) to skip that lookup.
- **Device type** - the device the card is being provisioned to: `phone` or `watch`. PassKit does not report the device the user selected, so the library infers it from where the card is already provisioned. When it cannot tell, the value is `unknown`.

# API Reference
The library offers functions for seamless integration and use of the Apple Wallet and Google Wallet APIs. Additionally, it includes listeners and handlers for managing provisioning events. Below, these functions are described along with the data types involved.
Expand All @@ -184,7 +186,8 @@ 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[]` | ❌ | ✅ |
| **addCardToAppleWallet** | Initiates native Push Provisioning flow for adding a card to the Apple Wallet. | `data`: `IOSCardData`,<br>`issuerEncrypt-`<br>`PayloadCallback: IOSIssuerCallback` | `void` | ✅ | ❌ |
| **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` | ✅ | ✅ |

## Data Types

Expand All @@ -196,9 +199,11 @@ The library offers functions for seamless integration and use of the Apple Walle
| **AndroidPaymentCredentialsResponse** | Response object containing encrypted payment credentials for UAPP flow. [Learn more →](./docs/UAPP.md) | `opaquePaymentCard: string`,<br>`googleOpaquePaymentCard: string` |
| **onPaymentCredentialsRequestPayload** | Payload received when Google Wallet requests payment credentials during UAPP provisioning. [Learn more →](./docs/UAPP.md) | `requestId: string`,<br>`serverSessionId: string`,<br>`walletId: string`,<br>`tokenRequestorId: string \| null`,<br>`opaquePaymentCard: string` |
| **UserAddress** | Structured address used for cardholder verification. | `name: string`,<br>`addressOne: string`,<br>`addressTwo: string`,<br>`city: string`,<br>`administrativeArea: string`,<br>`countryCode: string`,<br>`postalCode: string`,<br>`phoneNumber: string` |
| **IOSCardData** | Data related to a card that is to be added on iOS platform. | `network: string`,<br>`activationData: string`,<br>`encryptedPassData: string`,<br>`ephemeralPublicKey: string`,<br>`cardHolderTitle: string`,<br>`cardHolderName: string`,<br>`lastDigits: string`,<br>`cardDescription: string`,<br>`cardDescriptionComment: string` |
| **IOSCardData** | Data related to a card that is to be added on iOS. Set `primaryAccountIdentifier` after the first provisioning so the next attempt targets devices that do not already have the card. | `network: string`,<br>`activationData: string`,<br>`encryptedPassData: string`,<br>`ephemeralPublicKey: string`,<br>`cardHolderTitle: string`,<br>`cardHolderName: string`,<br>`lastDigits: string`,<br>`cardDescription: string`,<br>`cardDescriptionComment: string`,<br>`primaryAccountIdentifier?: string` |
| **onCardActivatedPayload** | Data used by listener to notice when a card's status changes. | `tokenId?: string \| null`,<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` |
| **IOSIssuerCallback** | This callback is invoked with a nonce, its signature, a certificate array obtained from Apple, and the device the card is being added to. 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. `deviceType` is a best-effort value; see `IOSDeviceType`. | `(nonce: string, nonceSignature: string, certificate: string[], deviceType: IOSDeviceType) => IOSEncryptPayload` |
| **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` |
| **TokenInfo** | Information about a token stored in Google Wallet. | `identifier: string`,<br>`lastDigits: string`,<br>`tokenState: number` |

Expand Down Expand Up @@ -292,6 +297,7 @@ const base64ToHex = (base64: string): string => Buffer.from(base64, "base64").to
nonce: string,
nonceSignature: string,
certificate: string[],
deviceType: IOSDeviceType,
): Promise<IOSEncryptPayload> => {
try {
const payload = {
Expand All @@ -300,6 +306,7 @@ const base64ToHex = (base64: string): string => Buffer.from(base64, "base64").to
nonceSignature: base64ToHex(nonceSignature),
wspCertificates: certificate,
},
deviceType,
serialNumber,
wspId: "APPLE" as const,
}
Expand Down Expand Up @@ -353,7 +360,7 @@ Please refer to the [complete testing criteria documentation](https://developers
### iOS

When implementing the In-App Push Provisioning feature in your App make sure that your app follows Apple's [branding guidelines connected to Apple Wallet](https://developer.apple.com/wallet/add-to-apple-wallet-guidelines/). Remember that you must not create your own buttons or your app could be rejected at revision. You can use the[ AddWalletButton component](#components) instead!
When the pass is already provisioned, make sure to hide this button and replace it with text like `Added to Apple Wallet`. The card is fully provisioned once it added to your main device (user's iPhone) and all linked devices (for example Apple Watch).
Keep the Add to Apple Wallet button visible while `canAddCardToAppleWallet(primaryAccountIdentifier, lastDigits)` returns `true`, including after the card is added to the iPhone but not yet to a paired Apple Watch. Show text like `Added to Apple Wallet` only once that call returns `false`. Pass `primaryAccountIdentifier` in `IOSCardData` so the next attempt targets the remaining device, and pass the same `lastDigits` to both calls so they agree. This matches [Apple's in-app provisioning guidance](https://applepaydemo.apple.com/in-app-provisioning#3.3).

Next to branding guidelines, please follow the instructions and best practices from [the In-App provisioning documentation]((#ios)) provided by Apple.

Expand Down
4 changes: 4 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,10 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
// no-op
}

override fun canAddCardToAppleWallet(primaryAccountIdentifier: String, lastDigits: String, promise: Promise) {
promise.resolve(false)
}

override fun addListener(eventType: String?) {
// no-op
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ public NativeWalletSpec(ReactApplicationContext reactContext) {
@DoNotStrip
public abstract void IOSHandleAddPaymentPassResponse(ReadableMap payload, Promise promise);

@ReactMethod
@DoNotStrip
public abstract void canAddCardToAppleWallet(String primaryAccountIdentifier, String lastDigits, Promise promise);

@ReactMethod
@DoNotStrip
public abstract void addListener(String eventType);
Expand Down
27 changes: 22 additions & 5 deletions ios/RNWallet.mm
Original file line number Diff line number Diff line change
Expand Up @@ -42,24 +42,32 @@ - (instancetype)init {
reject:(RCTPromiseRejectBlock)reject)
{
@try {
NSDictionary *cardDataDict = nil;
NSMutableDictionary *cardDataDict = nil;
NSString *primaryAccountIdentifier = nil;

#ifdef RCT_NEW_ARCH_ENABLED
cardDataDict = @{
cardDataDict = [@{
@"network": [self safeString:cardData.network()],
@"cardHolderName": [self safeString:cardData.cardHolderName()],
@"lastDigits": [self safeString:cardData.lastDigits()],
@"cardDescription": [self safeString:cardData.cardDescription()],
};
} mutableCopy];
primaryAccountIdentifier = [self safeString:cardData.primaryAccountIdentifier()];
#else
cardDataDict = @{
cardDataDict = [@{
@"network": [self safeString:cardData[@"network"]],
@"cardHolderName": [self safeString:cardData[@"cardHolderName"]],
@"lastDigits": [self safeString:cardData[@"lastDigits"]],
@"cardDescription": [self safeString:cardData[@"cardDescription"]],
};
} mutableCopy];
id rawIdentifier = cardData[@"primaryAccountIdentifier"];
primaryAccountIdentifier = [self safeString:[rawIdentifier isKindOfClass:[NSString class]] ? rawIdentifier : nil];
#endif

if (primaryAccountIdentifier.length > 0) {
cardDataDict[@"primaryAccountIdentifier"] = primaryAccountIdentifier;
}

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];
Expand Down Expand Up @@ -123,6 +131,15 @@ - (instancetype)init {
resolve([walletManager getCardStatusByIdentifierWithIdentifier:identifier]);
}

RCT_REMAP_METHOD(canAddCardToAppleWallet,
canAddCardToAppleWallet:(NSString *)primaryAccountIdentifier
lastDigits:(NSString *)lastDigits
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
{
resolve(@([walletManager canAddCardToAppleWalletWithPrimaryAccountIdentifier:primaryAccountIdentifier lastDigits:lastDigits]));
}

- (void)addCardToGoogleWallet:
#ifdef RCT_NEW_ARCH_ENABLED
(JS::NativeWallet::AndroidCardData &)cardData
Expand Down
90 changes: 88 additions & 2 deletions ios/WalletManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ open class WalletManager: UIViewController {
private var addPaymentPassCompletionHandler: (CompletionHandler)?

private var addPassHandler: ((PKAddPaymentPassRequest) -> Void)?

private var addPassPrimaryAccountIdentifier: String?

@objc public var packageName = "react-native-wallet"

Expand Down Expand Up @@ -111,10 +113,21 @@ open class WalletManager: UIViewController {
configuration.cardholderName = card.cardHolderName
configuration.primaryAccountSuffix = card.lastDigits
configuration.localizedDescription = String(card.cardDescription)
let primaryAccountIdentifier = card.primaryAccountIdentifier.map {
resolvedPrimaryAccountIdentifier($0, lastDigits: card.lastDigits)
}
if let primaryAccountIdentifier = primaryAccountIdentifier {
configuration.primaryAccountIdentifier = primaryAccountIdentifier
Comment thread
JakubKorytko marked this conversation as resolved.
Resolved
}

guard let enrollViewController = PKAddPaymentPassViewController(requestConfiguration: configuration, delegate: self) else {
let alreadyOnEveryDevice = primaryAccountIdentifier.map {
!passLibrary.canAddSecureElementPass(primaryAccountIdentifier: $0)
} ?? false
completion(.error, [
"errorMessage": "InApp enrollment controller configuration fails"
"errorMessage": alreadyOnEveryDevice
? "This card has already been added to all available devices."
: "InApp enrollment controller configuration fails"
])
return
}
Expand All @@ -134,6 +147,7 @@ open class WalletManager: UIViewController {
self.presentAddPaymentPassCompletionHandler = nil
self.addPaymentPassCompletionHandler = nil
self.addPassHandler = nil
self.addPassPrimaryAccountIdentifier = nil

let errorResponse: NSDictionary = [
"errorMessage": "EnrollViewController is no longer presented."
Expand All @@ -150,6 +164,7 @@ open class WalletManager: UIViewController {

self.presentAddPaymentPassCompletionHandler = completion
self.addPassViewController = enrollViewController
self.addPassPrimaryAccountIdentifier = primaryAccountIdentifier
presentingViewController.present(enrollViewController, animated: true, completion: nil)
}
}
Expand Down Expand Up @@ -212,6 +227,75 @@ open class WalletManager: UIViewController {
return pass.primaryAccountIdentifier == identifier as String
}
}

@objc public func canAddCardToAppleWallet(primaryAccountIdentifier: NSString, lastDigits: NSString) -> Bool {
let suffix = lastDigits as String
let resolved = suffix.isEmpty
? primaryAccountIdentifier as String
: resolvedPrimaryAccountIdentifier(primaryAccountIdentifier as String, lastDigits: suffix)
return passLibrary.canAddSecureElementPass(primaryAccountIdentifier: resolved)
}

private func provisioningTargetDevice(for primaryAccountIdentifier: String?) -> ProvisioningDeviceType {
guard let primaryAccountIdentifier = primaryAccountIdentifier else {
return .unknown
}

let onPhone = hasProvisionedPass(identifier: primaryAccountIdentifier, in: passLibrary.passes(of: .secureElement))
let onWatch = hasProvisionedPass(identifier: primaryAccountIdentifier, in: passLibrary.remoteSecureElementPasses)

if onPhone && !onWatch {
return .watch
}
if onWatch && !onPhone {
return .phone
}
return .unknown
}

private func resolvedPrimaryAccountIdentifier(_ identifier: String, lastDigits: String) -> String {
let passes = allSecureElementPasses()
if passes.contains(where: { $0.primaryAccountIdentifier == identifier }) {
return identifier
}

guard !lastDigits.isEmpty else {
return identifier
}

let suffixMatches = passes.filter {
$0.passActivationState != .deactivated && $0.primaryAccountNumberSuffix.hasSuffix(lastDigits)
}
let identifiers = Set(suffixMatches.map(\.primaryAccountIdentifier))
if identifiers.count == 1, let resolvedIdentifier = identifiers.first {
return resolvedIdentifier
}
return identifier
}

private func allSecureElementPasses() -> [PKSecureElementPass] {
let localPasses = passLibrary.passes(of: .secureElement)
.filter { !$0.isRemotePass }
.compactMap(\.secureElementPass)
return localPasses + passLibrary.remoteSecureElementPasses
}

private func hasProvisionedPass(identifier: String, in passes: [PKPass]) -> Bool {
passes.contains { pass in
guard !pass.isRemotePass, let securePass = pass.secureElementPass else {
return false
}
return isProvisioned(securePass, identifier: identifier)
}
}

private func hasProvisionedPass(identifier: String, in passes: [PKSecureElementPass]) -> Bool {
passes.contains { isProvisioned($0, identifier: identifier) }
}

private func isProvisioned(_ pass: PKSecureElementPass, identifier: String) -> Bool {
pass.primaryAccountIdentifier == identifier && pass.passActivationState != .deactivated
}

private func isPassKitAvailable() -> Bool {
return PKAddPaymentPassViewController.canAddPaymentPass()
Expand Down Expand Up @@ -261,7 +345,8 @@ extension WalletManager: PKAddPaymentPassViewControllerDelegate {
let stringCertificates = certificates.map {
$0.base64EncodedString() as NSString
}
let reqestCardData = AddPassResponse(status: .completed, nonce: stringNonce, nonceSignature: stringNonceSignature, certificates: stringCertificates)
let deviceType = provisioningTargetDevice(for: addPassPrimaryAccountIdentifier)
let reqestCardData = AddPassResponse(status: .completed, nonce: stringNonce, nonceSignature: stringNonceSignature, certificates: stringCertificates, deviceType: deviceType)
self.addPassHandler = handler

// Retry the JS issuer callback if the user tries again to add a payment pass
Expand Down Expand Up @@ -293,6 +378,7 @@ extension WalletManager: PKAddPaymentPassViewControllerDelegate {
presentAddPaymentPassCompletionHandler = nil
addPaymentPassCompletionHandler = nil
addPassHandler = nil
addPassPrimaryAccountIdentifier = nil

let errorMessage = error?.localizedDescription ?? ""

Expand Down
Loading
Loading