Skip to content

Commit 15545d7

Browse files
authored
feat: add support for custom headers in addPassFromUrl (#41)
* feat: add support for custom headers in addPassFromUrl Updates addPassFromUrl to accept optional headers parameter for authenticated requests. Changes iOS implementation from direct Data(contentsOf:) to URLSession with proper header and error handling. * fix(ios): resolve promise when pass already exists in wallet Set completion handler before calling showViewController so that when containsPass() returns true, the completion callback is properly invoked instead of being nil. * feat: add detailed error codes to addPassFromUrl - Change completion handler to return error code and message - Use Promise rejection instead of resolve(false) for failures - Add specific error codes: INVALID_URL, NETWORK_ERROR, HTTP_ERROR, INVALID_DATA, INVALID_PASS, PASS_ALREADY_EXISTS, NO_VIEW_CONTROLLER, CONTROLLER_ERROR, USER_CANCELLED - Export WalletErrorCode and WalletError types for TypeScript consumers * chore: add .yarn/ to gitignore * fix: address code review issues in addPassFromUrl - Accept all 2xx HTTP status codes, not just 200 - Replace deprecated keyWindow with UIWindowScene API - Remove redundant message property from WalletError type * feat(android): implement addPassFromUrl with URL fetch and headers - Fetch JWT from URL with optional custom headers support - Pass fetched JWT to Google Wallet savePassesJwt API - Add consistent error codes: INVALID_URL, NETWORK_ERROR, HTTP_ERROR, NO_ACTIVITY, API_ERROR, GENERAL_ERROR - Remove Linking.openURL fallback, use native implementation - Update WalletErrorCode type with Android-specific codes
1 parent 4c0f024 commit 15545d7

File tree

6 files changed

+174
-38
lines changed

6 files changed

+174
-38
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ node_modules/
4646
npm-debug.log
4747
yarn-debug.log
4848
yarn-error.log
49+
.yarn/
4950

5051
# BUCK
5152
buck-out/

android/src/main/java/com/reactnativewalletmanager/WalletManagerModule.kt

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ import android.content.Intent;
55
import com.facebook.react.bridge.BaseActivityEventListener;
66
import com.facebook.react.bridge.Promise;
77
import com.facebook.react.bridge.ReactApplicationContext;
8+
import com.facebook.react.bridge.ReadableMap;
89
import com.google.android.gms.pay.PayClient;
910
import com.google.android.gms.pay.Pay;
1011
import com.google.android.gms.pay.PayApiAvailabilityStatus;
12+
import java.net.HttpURLConnection;
13+
import java.net.URL;
14+
import java.net.MalformedURLException;
1115

1216
class WalletManagerModule(reactContext: ReactApplicationContext) : NativeWalletManagerSpec(reactContext) {
1317

@@ -93,8 +97,71 @@ class WalletManagerModule(reactContext: ReactApplicationContext) : NativeWalletM
9397
promise.reject("UNSUPPORTED_PLATFORM", "This function is not supported on Android.")
9498
}
9599

96-
override fun addPassFromUrl(url: String, promise: Promise) {
97-
promise.reject("UNSUPPORTED_PLATFORM", "This function is not supported on Android.")
100+
override fun addPassFromUrl(url: String, headers: ReadableMap?, promise: Promise) {
101+
val activity = currentActivity
102+
if (activity == null) {
103+
promise.reject("NO_ACTIVITY", "Current activity is unavailable")
104+
return
105+
}
106+
107+
// Validate URL before starting network request
108+
val parsedUrl: URL
109+
try {
110+
parsedUrl = URL(url)
111+
} catch (e: MalformedURLException) {
112+
promise.reject("INVALID_URL", "The URL is invalid")
113+
return
114+
}
115+
116+
// Run network request on background thread
117+
Thread {
118+
var connection: HttpURLConnection? = null
119+
try {
120+
connection = parsedUrl.openConnection() as HttpURLConnection
121+
connection.requestMethod = "GET"
122+
123+
// Add custom headers
124+
headers?.let { map ->
125+
val iterator = map.keySetIterator()
126+
while (iterator.hasNextKey()) {
127+
val key = iterator.nextKey()
128+
connection.setRequestProperty(key, map.getString(key))
129+
}
130+
}
131+
132+
val responseCode = connection.responseCode
133+
if (responseCode !in 200..299) {
134+
promise.reject("HTTP_ERROR", "HTTP $responseCode")
135+
return@Thread
136+
}
137+
138+
val jwt = connection.inputStream.bufferedReader().use { it.readText() }
139+
140+
// Save to Google Wallet on main thread
141+
activity.runOnUiThread {
142+
addPassPromise = promise
143+
try {
144+
walletClient.savePassesJwt(jwt, activity, ADD_TO_GOOGLE_WALLET_REQUEST_CODE)
145+
} catch (e: Exception) {
146+
addPassPromise = null
147+
when (e) {
148+
is com.google.android.gms.common.api.ApiException -> {
149+
val statusCode = e.statusCode
150+
val statusMessage = com.google.android.gms.common.api.CommonStatusCodes.getStatusCodeString(statusCode)
151+
promise.reject("API_ERROR", "Google Pay API error: $statusCode - $statusMessage")
152+
}
153+
else -> {
154+
promise.reject("GENERAL_ERROR", e.message ?: "Failed to save pass")
155+
}
156+
}
157+
}
158+
}
159+
} catch (e: Exception) {
160+
promise.reject("NETWORK_ERROR", e.message ?: "Network request failed")
161+
} finally {
162+
connection?.disconnect()
163+
}
164+
}.start()
98165
}
99166

100167
override fun hasPass(cardIdentifier: String, serialNumber: String?, promise: Promise) {

ios/WalletManager.mm

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@ @implementation NativeWalletManager
2222
return std::make_shared<facebook::react::NativeWalletManagerSpecJSI>(params);
2323
}
2424

25-
- (void)addPassFromUrl:(nonnull NSString *)url resolve:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
26-
[walletManager addPassFromUrl:url completion:^(BOOL added) {
27-
resolve(@(added));
28-
}];
25+
- (void)addPassFromUrl:(nonnull NSString *)url headers:(nullable NSDictionary *)headers resolve:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
26+
[walletManager addPassFromUrl:url headers:headers completion:^(BOOL success, NSString *errorCode, NSString *errorMessage) {
27+
if (success) {
28+
resolve(@(YES));
29+
} else {
30+
reject(errorCode, errorMessage, nil);
31+
}
32+
}];
2933
}
3034

3135
- (void)addPassToGoogleWallet:(nonnull NSString *)jwt resolve:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {

ios/WalletManagerImpl.swift

Lines changed: 65 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import PassKit
1212
public class WalletManagerImpl: NSObject, @preconcurrency PKAddPassesViewControllerDelegate {
1313
private var pass: PKPass?
1414
private var passLibrary: PKPassLibrary?
15-
private var completion: ((Bool) -> Void)?
15+
private var completion: ((Bool, String?, String?) -> Void)?
1616

1717
// MARK: - canAddPasses
1818

@@ -36,24 +36,55 @@ public class WalletManagerImpl: NSObject, @preconcurrency PKAddPassesViewControl
3636

3737
// MARK: - addPassFromUrl
3838

39-
@MainActor @objc(addPassFromUrl:completion:)
39+
@MainActor @objc(addPassFromUrl:headers:completion:)
4040
public func addPassFromUrl(_ passUrlString: String,
41-
completion: @escaping (Bool) -> Void
41+
headers: [String: String]?,
42+
completion: @escaping (Bool, String?, String?) -> Void
4243
) {
4344
guard let url = URL(string: passUrlString) else {
44-
completion(false)
45-
print("wallet", "The pass URL is invalid")
45+
completion(false, "INVALID_URL", "The pass URL is invalid")
4646
return
4747
}
48-
49-
guard let data = try? Data(contentsOf: url) else {
50-
completion(false)
51-
print("wallet", "The pass data is invalid")
52-
return
48+
49+
var request = URLRequest(url: url)
50+
request.httpMethod = "GET"
51+
52+
// Add custom headers (for authentication)
53+
if let headers = headers {
54+
for (key, value) in headers {
55+
request.setValue(value, forHTTPHeaderField: key)
56+
}
5357
}
54-
55-
self.showViewController(with: data)
56-
self.completion = completion
58+
59+
URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
60+
guard let self = self else { return }
61+
62+
if let error = error {
63+
DispatchQueue.main.async {
64+
completion(false, "NETWORK_ERROR", error.localizedDescription)
65+
}
66+
return
67+
}
68+
69+
if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
70+
DispatchQueue.main.async {
71+
completion(false, "HTTP_ERROR", "HTTP \(httpResponse.statusCode)")
72+
}
73+
return
74+
}
75+
76+
guard let data = data else {
77+
DispatchQueue.main.async {
78+
completion(false, "INVALID_DATA", "No data received")
79+
}
80+
return
81+
}
82+
83+
DispatchQueue.main.async {
84+
self.completion = completion
85+
self.showViewController(with: data)
86+
}
87+
}.resume()
5788
}
5889

5990
// MARK: - hasPass
@@ -113,22 +144,25 @@ public class WalletManagerImpl: NSObject, @preconcurrency PKAddPassesViewControl
113144

114145
@MainActor private func showViewController(with data: Data) {
115146
guard let pass = try? PKPass(data: data) else {
116-
addPassCompletion(false)
117-
print("wallet", "The pass is invalid")
147+
addPassCompletion(false, "INVALID_PASS", "Failed to parse pass data")
118148
return
119149
}
120150

121151
self.pass = pass
122152
self.passLibrary = PKPassLibrary()
123153

124154
if self.passLibrary?.containsPass(pass) == true {
125-
addPassCompletion(false)
126-
return print("wallet", "pass already added")
155+
addPassCompletion(false, "PASS_ALREADY_EXISTS", "This pass is already in your wallet")
156+
return
127157
}
128158

129-
guard let root = UIApplication.shared.keyWindow?.rootViewController else {
130-
addPassCompletion(false)
131-
return print("wallet", "No rootViewController found")
159+
guard let windowScene = UIApplication.shared.connectedScenes
160+
.compactMap({ $0 as? UIWindowScene })
161+
.first(where: { $0.activationState == .foregroundActive }),
162+
let root = windowScene.windows.first(where: { $0.isKeyWindow })?.rootViewController
163+
else {
164+
addPassCompletion(false, "NO_VIEW_CONTROLLER", "No root view controller found")
165+
return
132166
}
133167

134168
var top = root
@@ -137,8 +171,8 @@ public class WalletManagerImpl: NSObject, @preconcurrency PKAddPassesViewControl
137171
}
138172

139173
guard let controller = PKAddPassesViewController(pass: pass) else {
140-
addPassCompletion(false)
141-
return print("wallet", "no PKAddPassesViewController")
174+
addPassCompletion(false, "CONTROLLER_ERROR", "Failed to create pass controller")
175+
return
142176
}
143177
controller.delegate = self
144178

@@ -154,7 +188,12 @@ public class WalletManagerImpl: NSObject, @preconcurrency PKAddPassesViewControl
154188
if
155189
let pass = self.pass,
156190
let library = self.passLibrary {
157-
addPassCompletion(library.containsPass(pass))
191+
let wasAdded = library.containsPass(pass)
192+
if wasAdded {
193+
addPassCompletion(true, nil, nil)
194+
} else {
195+
addPassCompletion(false, "USER_CANCELLED", "User cancelled or declined to add pass")
196+
}
158197
}
159198
self.passLibrary = nil
160199
self.pass = nil
@@ -163,9 +202,9 @@ public class WalletManagerImpl: NSObject, @preconcurrency PKAddPassesViewControl
163202
}
164203

165204
// MARK: - Helper
166-
167-
private func addPassCompletion(_ result: Bool) {
168-
self.completion?(result)
205+
206+
private func addPassCompletion(_ success: Bool, _ errorCode: String?, _ errorMessage: String?) {
207+
self.completion?(success, errorCode, errorMessage)
169208
self.completion = nil
170209
}
171210

src/index.tsx

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,33 @@
1-
import { Linking, Platform } from 'react-native';
1+
import { Platform } from 'react-native';
22
import WalletManager from './specs/NativeWalletManager';
33

4+
/**
5+
* Error codes that can be thrown by addPassFromUrl
6+
*/
7+
export type WalletErrorCode =
8+
// Shared error codes
9+
| 'INVALID_URL'
10+
| 'NETWORK_ERROR'
11+
| 'HTTP_ERROR'
12+
| 'USER_CANCELLED'
13+
// iOS-specific error codes
14+
| 'INVALID_DATA'
15+
| 'INVALID_PASS'
16+
| 'PASS_ALREADY_EXISTS'
17+
| 'NO_VIEW_CONTROLLER'
18+
| 'CONTROLLER_ERROR'
19+
// Android-specific error codes
20+
| 'NO_ACTIVITY'
21+
| 'API_ERROR'
22+
| 'GENERAL_ERROR';
23+
24+
/**
25+
* Error object thrown when addPassFromUrl fails
26+
*/
27+
export type WalletError = Error & {
28+
code: WalletErrorCode;
29+
};
30+
431
export default {
532
canAddPasses: async () => {
633
return await WalletManager.canAddPasses();
@@ -19,10 +46,8 @@ export default {
1946
}
2047
return await WalletManager.addPassToGoogleWallet(jwt);
2148
},
22-
addPassFromUrl:
23-
Platform.OS === 'ios'
24-
? WalletManager.addPassFromUrl
25-
: (url: string) => Linking.openURL(url),
49+
addPassFromUrl: (url: string, headers?: Record<string, string>) =>
50+
WalletManager.addPassFromUrl(url, headers ?? null),
2651
hasPass: async (cardIdentifier: string, serialNumber?: string) => {
2752
if (Platform.OS === 'android') {
2853
throw new Error('hasPass method not available on Android');

src/specs/NativeWalletManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { TurboModule, TurboModuleRegistry } from 'react-native';
33
export interface Spec extends TurboModule {
44
canAddPasses(): Promise<boolean>;
55
showAddPassControllerFromFile(url: string): Promise<boolean>;
6-
addPassFromUrl(url: string): Promise<boolean>;
6+
addPassFromUrl(url: string, headers: Object | null): Promise<boolean>;
77
hasPass(
88
cardIdentifier: string,
99
serialNumber: string | null

0 commit comments

Comments
 (0)