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
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,11 @@ 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`. On iOS, `getCardStatusByIdentifier` uses this same value. If no pass matches the given id exactly, the library uses the id of the only pass with the same last digits.
- **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 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 nine 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 +183,8 @@ 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[]` | ❌ | ✅ |
| **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 any of the user's devices (iPhone or paired Apple Watch). | `primaryAccountIdentifier: string` | `boolean` | ✅ | ❌ |


## Data Types
Expand All @@ -192,9 +195,11 @@ The library offers seven functions for seamless integration and use of the Apple
| **AndroidCardData** | Data related to a card that is to be added on Android platform wallets. | `network: string`,<br>`opaquePaymentCard: string`,<br>`cardHolderName: string`,<br>`lastDigits: string`,<br>`userAddress: UserAddress` |
| **AndroidResumeCardData** | Simplified data structure for resuming card addition to Google Wallet using existing token reference ID. | `network: string`,<br>`tokenReferenceID: string`,<br>`cardHolderName?: string`,<br>`lastDigits?: 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`,<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. If no pass matches that id exactly, the library uses the id of the only pass with the same last digits. 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 Apple returns when the user continues the provisioning flow, including the device the card is being added to. | `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 @@ -267,6 +272,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 @@ -275,6 +281,7 @@ const base64ToHex = (base64: string): string => Buffer.from(base64, "base64").to
nonceSignature: base64ToHex(nonceSignature),
wspCertificates: certificate,
},
deviceType,
serialNumber,
wspId: "APPLE" as const,
}
Expand All @@ -301,7 +308,7 @@ The app will need to be reviewed by Google. During this process, it will need to
### 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)` 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. 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 @@ -365,6 +365,10 @@ class WalletModule internal constructor(context: ReactApplicationContext) :
// no-op
}

override fun canAddCardToAppleWallet(primaryAccountIdentifier: String, 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 @@ -73,6 +73,10 @@ public NativeWalletSpec(ReactApplicationContext reactContext) {
@DoNotStrip
public abstract void IOSHandleAddPaymentPassResponse(ReadableMap payload, Promise promise);

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

@ReactMethod
@DoNotStrip
public abstract void addListener(String eventType);
Expand Down
26 changes: 21 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,14 @@ - (instancetype)init {
resolve([walletManager getCardStatusByIdentifierWithIdentifier:identifier]);
}

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

- (void)addCardToGoogleWallet:
#ifdef RCT_NEW_ARCH_ENABLED
(JS::NativeWallet::AndroidCardData &)cardData
Expand Down
81 changes: 79 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 addPassTargetDevice: String?

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

Expand Down Expand Up @@ -111,10 +113,23 @@ 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

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.

Not sure about this one: what happens when the pass with this id is already on every device (iPhone and Watch)? If PassKit has nowhere to add it, PKAddPaymentPassViewController(...) might return nil and the user gets "InApp enrollment controller configuration fails", which is confusing. Did you get a chance to check that case? If it is a real problem, PKPassLibrary().canAddSecureElementPass(primaryAccountIdentifier:) could be used to give a clearer error.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Checked this. When primaryAccountIdentifier is set and PKPassLibrary.canAddSecureElementPass(primaryAccountIdentifier:) is false, we now reject before creating the controller, with "This card has already been added to all available devices." The same check is exposed as canAddCardToAppleWallet, so the app can hide the Add button before the user taps it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Follow-up: this check is now advisory. canAddSecureElementPass has been reported to return false after a card is added to the iPhone even when a paired Watch can still take it (https://developer.apple.com/forums/thread/113967), so a false result no longer blocks provisioning. We only use it to choose the error message when PKAddPaymentPassViewController itself returns nil.

}

let targetDevice = provisioningTargetDevice(for: primaryAccountIdentifier)

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 +149,7 @@ open class WalletManager: UIViewController {
self.presentAddPaymentPassCompletionHandler = nil
self.addPaymentPassCompletionHandler = nil
self.addPassHandler = nil
self.addPassTargetDevice = nil

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

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

@objc public func canAddCardToAppleWallet(primaryAccountIdentifier: NSString) -> Bool {
return passLibrary.canAddSecureElementPass(primaryAccountIdentifier: primaryAccountIdentifier as String)
}

private func provisioningTargetDevice(for primaryAccountIdentifier: String?) -> String {
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
}

let suffixMatches = passes.filter { $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 +337,7 @@ extension WalletManager: PKAddPaymentPassViewControllerDelegate {
let stringCertificates = certificates.map {
$0.base64EncodedString() as NSString
}
let reqestCardData = AddPassResponse(status: .completed, nonce: stringNonce, nonceSignature: stringNonceSignature, certificates: stringCertificates)
let reqestCardData = AddPassResponse(status: .completed, nonce: stringNonce, nonceSignature: stringNonceSignature, certificates: stringCertificates, deviceType: addPassTargetDevice as NSString?)
self.addPassHandler = handler

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

let errorMessage = error?.localizedDescription ?? ""

Expand Down
23 changes: 20 additions & 3 deletions ios/stucts/AddPassResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,25 @@ struct AddPassResponse {
let nonce: NSString?
let nonceSignature: NSString?
let certificates: [NSString]?
let deviceType: NSString?

init(status: OperationResult, nonce: NSString?, nonceSignature: NSString?, certificates: [NSString]?, deviceType: NSString? = nil) {
self.status = status
self.nonce = nonce
self.nonceSignature = nonceSignature
self.certificates = certificates
self.deviceType = deviceType
}

func toNSDictionary() -> NSDictionary {
if status.rawValue > 0 {
return [
var response: [String: Any] = [
"status": status.rawValue,
]
if let deviceType = deviceType {
response["deviceType"] = deviceType
}
return response as NSDictionary
}

guard let nonce = nonce,
Expand All @@ -19,12 +32,16 @@ struct AddPassResponse {
return [:]
}

return [
var response: [String: Any] = [
"status": status.rawValue,
"nonce": nonce,
"nonceSignature": nonceSignature,
"certificates": certificates
"certificates": certificates,
]
if let deviceType = deviceType {
response["deviceType"] = deviceType
}
return response as NSDictionary
}
}

Expand Down
6 changes: 6 additions & 0 deletions ios/stucts/CardInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ struct CardInfo {
let cardHolderName: String
let lastDigits: String
let cardDescription: String
let primaryAccountIdentifier: String?

init(cardData: NSDictionary) throws {
guard let networkString = cardData["network"] as? String, !networkString.isEmpty,
Expand All @@ -19,6 +20,11 @@ struct CardInfo {
self.cardHolderName = cardHolderName
self.lastDigits = lastDigits
self.cardDescription = cardDescription
if let identifier = cardData["primaryAccountIdentifier"] as? String, !identifier.isEmpty {
self.primaryAccountIdentifier = identifier
} else {
self.primaryAccountIdentifier = nil
}
}

private static func getNetwork(from identifier: String) -> PKPaymentNetwork? {
Expand Down
Loading
Loading