From fedf393f38c6951b66f3e81e3043e36fae0770de Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Tue, 27 Oct 2026 17:43:32 -0300 Subject: [PATCH 1/2] feat: open an installed Apple Wallet pass from the app Card Home can jump to the iPhone pass that is still waiting for verification. --- README.md | 1 + .../java/com/expensify/wallet/WalletModule.kt | 4 ++++ .../com/expensify/wallet/NativeWalletSpec.java | 4 ++++ ios/RNWallet.mm | 10 ++++++++++ ios/WalletManager.swift | 15 +++++++++++++++ src/NativeWallet.ts | 1 + src/index.tsx | 13 +++++++++++++ 7 files changed, 48 insertions(+) diff --git a/README.md b/README.md index 7ad2db33..1dd91eac 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,7 @@ 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[]` | ❌ | ✅ | +| **presentAppleWalletPass** | Opens the installed iPhone pass for these last digits, preferring one that still needs verification. Returns `false` when that pass is not installed, and on Android. | `lastDigits: string` | `boolean` | ✅ | ✅ | | **addCardToAppleWallet** | Initiates native Push Provisioning flow for adding a card to the Apple Wallet. | `data`: `IOSCardData`,
`issuerEncrypt-`
`PayloadCallback: IOSIssuerCallback` | `void` | ✅ | ❌ | ## Data Types diff --git a/android/src/main/java/com/expensify/wallet/WalletModule.kt b/android/src/main/java/com/expensify/wallet/WalletModule.kt index a77dee0a..83d69a18 100644 --- a/android/src/main/java/com/expensify/wallet/WalletModule.kt +++ b/android/src/main/java/com/expensify/wallet/WalletModule.kt @@ -580,6 +580,10 @@ class WalletModule internal constructor(context: ReactApplicationContext) : .emit(eventName, params) } + override fun presentAppleWalletPass(lastDigits: String, promise: Promise?) { + promise?.resolve(false) + } + override fun IOSPresentAddPaymentPassView(cardData: ReadableMap?, promise: Promise?) { // no-op } diff --git a/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java b/android/src/paper/java/com/expensify/wallet/NativeWalletSpec.java index 052fb38b..7b9ac0c6 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 presentAppleWalletPass(String lastDigits, Promise promise); + @ReactMethod @DoNotStrip public abstract void IOSPresentAddPaymentPassView(ReadableMap cardData, Promise promise); diff --git a/ios/RNWallet.mm b/ios/RNWallet.mm index 79d8b56d..34406592 100644 --- a/ios/RNWallet.mm +++ b/ios/RNWallet.mm @@ -114,6 +114,16 @@ - (instancetype)init { resolve([walletManager getCardStatusBySuffixWithLast4Digits:last4Digits]); } +RCT_REMAP_METHOD(presentAppleWalletPass, + presentAppleWalletPass:(NSString *)lastDigits + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + dispatch_async(dispatch_get_main_queue(), ^{ + resolve(@([self->walletManager presentAppleWalletPassWithLastDigits:lastDigits])); + }); +} + RCT_REMAP_METHOD(getCardStatusByIdentifier, getCardStatusByIdentifier:(NSString *)identifier tsp:(NSString *)tsp diff --git a/ios/WalletManager.swift b/ios/WalletManager.swift index 6b3e4c35..7eaf85cb 100644 --- a/ios/WalletManager.swift +++ b/ios/WalletManager.swift @@ -212,6 +212,21 @@ open class WalletManager: UIViewController { return pass.primaryAccountIdentifier == identifier as String } } + + @objc public func presentAppleWalletPass(lastDigits: NSString) -> Bool { + let candidates = passLibrary.passes(of: .secureElement) + .filter { !$0.isRemotePass } + .compactMap(\.secureElementPass) + .filter { + $0.primaryAccountNumberSuffix.hasSuffix(lastDigits as String) && + $0.passActivationState != .deactivated + } + guard let pass = candidates.first(where: { $0.passActivationState == .requiresActivation }) ?? candidates.first else { + return false + } + passLibrary.present(pass) + return true + } private func isPassKitAvailable() -> Bool { return PKAddPaymentPassViewController.canAddPaymentPass() diff --git a/src/NativeWallet.ts b/src/NativeWallet.ts index e4100c74..6fcc9c0b 100644 --- a/src/NativeWallet.ts +++ b/src/NativeWallet.ts @@ -96,6 +96,7 @@ export interface Spec extends TurboModule { AndroidRejectPaymentCredentials(requestId: string, errorMessage: string): Promise; resumeAddCardToGoogleWallet(cardData: AndroidResumeCardData): Promise; listTokens(): Promise; + presentAppleWalletPass(lastDigits: string): Promise; IOSPresentAddPaymentPassView(cardData: IOSCardData): Promise; IOSHandleAddPaymentPassResponse(payload: IOSEncryptPayload): Promise; addListener: (eventType: string) => void; diff --git a/src/index.tsx b/src/index.tsx index ec3d62bb..01d6e545 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -215,6 +215,18 @@ async function addCardToAppleWallet( return getTokenizationStatus(status); } +async function presentAppleWalletPass(lastDigits: string): Promise { + if (Platform.OS === 'android') { + return false; + } + + if (!Wallet) { + return getModuleLinkingRejection(); + } + + return Wallet.presentAppleWalletPass(lastDigits); +} + export type { AndroidCardData, AndroidWalletData, @@ -238,6 +250,7 @@ export { addCardToGoogleWallet, resumeAddCardToGoogleWallet, listTokens, + presentAppleWalletPass, addCardToAppleWallet, addListener, removeListener, From 654a2d53499ea8771203eec1be247ac27a7d5347 Mon Sep 17 00:00:00 2001 From: Bruno Nascimento Date: Thu, 29 Oct 2026 09:52:17 -0300 Subject: [PATCH 2/2] fix: refuse an ambiguous Apple Wallet pass An empty suffix matches every pass, and two cards that end in the same digits are not one card, so neither case opens Wallet. --- README.md | 2 +- ios/WalletManager.swift | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index baaf4cc5..b23e4434 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ 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[]` | ❌ | ✅ | -| **presentAppleWalletPass** | Opens the installed iPhone pass for these last digits, preferring one that still needs verification. Returns `false` when that pass is not installed, and on Android. | `lastDigits: string` | `boolean` | ✅ | ✅ | +| **presentAppleWalletPass** | Opens the installed iPhone pass for these last digits, preferring one that still needs verification. Returns `false` when `lastDigits` is empty, when no pass matches, when more than one card ends in those digits, and on Android. | `lastDigits: string` | `boolean` | ✅ | ✅ | | **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` | ✅ | ✅ | diff --git a/ios/WalletManager.swift b/ios/WalletManager.swift index 9c6b29d1..9f831d1e 100644 --- a/ios/WalletManager.swift +++ b/ios/WalletManager.swift @@ -229,14 +229,20 @@ open class WalletManager: UIViewController { } @objc public func presentAppleWalletPass(lastDigits: NSString) -> Bool { + let suffix = lastDigits as String + guard !suffix.isEmpty else { + return false + } + let candidates = passLibrary.passes(of: .secureElement) .filter { !$0.isRemotePass } .compactMap(\.secureElementPass) .filter { - $0.primaryAccountNumberSuffix.hasSuffix(lastDigits as String) && + $0.primaryAccountNumberSuffix.hasSuffix(suffix) && $0.passActivationState != .deactivated } - guard let pass = candidates.first(where: { $0.passActivationState == .requiresActivation }) ?? candidates.first else { + guard Set(candidates.map(\.primaryAccountIdentifier)).count == 1, + let pass = candidates.first(where: { $0.passActivationState == .requiresActivation }) ?? candidates.first else { return false } passLibrary.present(pass)