Skip to content

Commit d168760

Browse files
RhysAtBoltclaude
andauthored
fix(android): fix Google Pay crash — Activity context and ActivityEventListener (#49)
### Description Fix two bugs that caused Google Pay to crash and a third that sent the wrong auth header when fetching the Bolt APM config. **1. `PaymentsClient` created with wrong context (`GooglePayModule.kt`)** `Wallet.getPaymentsClient()` was called with `reactApplicationContext` (the Application context). The Google Pay SDK needs an `Activity` context to attach the payment sheet to a window — hence `Tried to show an alert while not attached to an Activity`. **2. `onActivityResult` never received (`GooglePayModule.kt`)** `GooglePayModule` never registered as an `ActivityEventListener`, so the result from the Google Pay sheet was silently swallowed and the pending `Promise` would hang forever. The module now implements `ActivityEventListener` and registers itself in `init{}` — no changes to `MainActivity` required. **3. Wrong header when fetching APM config (`GoogleWallet.tsx`)** The `fetchGooglePayAPMConfig` request used `merchant_token` as the header name instead of the correct `x-publishable-key`, causing the config fetch to fail before the payment sheet could even be shown. ### Testing - [ ] Tap Google Pay button on Android — payment sheet opens without crash - [ ] Complete a Google Pay payment — `onComplete` callback fires with token + billing address - [ ] Cancel/dismiss the Google Pay sheet — `onError` callback fires with `CANCELLED` - [ ] Cold-start the app and tap Google Pay immediately — no `NO_ACTIVITY` rejection ### Security Review > [!IMPORTANT] > A security review is required for every PR in this repository to comply with PCI requirements. - [x] I have considered and reviewed security implications of this PR and included the summary below. #### Security Impact Summary No new data flows or secrets introduced. The `x-publishable-key` header fix corrects which key is sent to the Bolt APM config endpoint — the key itself was already present in the original code, just under the wrong header name. The Activity context change only affects where Google Pay's system UI is anchored; no payment data handling is altered. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fe76c55 commit d168760

6 files changed

Lines changed: 157 additions & 44 deletions

File tree

android/src/main/java/com/boltreactnativesdk/GooglePayModule.kt

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.boltreactnativesdk
22

33
import android.app.Activity
4+
import android.content.Intent
5+
import com.facebook.react.bridge.ActivityEventListener
46
import com.facebook.react.bridge.Promise
57
import com.facebook.react.bridge.ReactApplicationContext
68
import com.facebook.react.bridge.ReactContextBaseJavaModule
@@ -27,7 +29,12 @@ import java.net.URL
2729
* and passed down in the config JSON.
2830
*/
2931
class GooglePayModule(reactContext: ReactApplicationContext) :
30-
ReactContextBaseJavaModule(reactContext) {
32+
ReactContextBaseJavaModule(reactContext),
33+
ActivityEventListener {
34+
35+
init {
36+
reactContext.addActivityEventListener(this)
37+
}
3138

3239
companion object {
3340
const val NAME = "BoltGooglePay"
@@ -36,26 +43,48 @@ class GooglePayModule(reactContext: ReactApplicationContext) :
3643

3744
override fun getName(): String = NAME
3845

39-
private var paymentsClient: PaymentsClient? = null
4046
private var pendingPromise: Promise? = null
4147
private var pendingPublishableKey: String = ""
4248
private var pendingBaseUrl: String = ""
4349

44-
private fun getPaymentsClient(): PaymentsClient {
45-
if (paymentsClient == null) {
46-
val walletOptions = Wallet.WalletOptions.Builder()
47-
.setEnvironment(WalletConstants.ENVIRONMENT_TEST)
48-
.build()
49-
paymentsClient = Wallet.getPaymentsClient(reactApplicationContext, walletOptions)
50+
private fun getPaymentsClient(activity: Activity, walletEnv: Int): PaymentsClient {
51+
val walletOptions = Wallet.WalletOptions.Builder()
52+
.setEnvironment(walletEnv)
53+
.build()
54+
return Wallet.getPaymentsClient(activity, walletOptions)
55+
}
56+
57+
/**
58+
* Maps the JS-side googlePayEnvironment string ("PRODUCTION" | "TEST") to
59+
* the matching WalletConstants value. Defaults to ENVIRONMENT_TEST so that
60+
* staging / sandbox traffic never hits the production Google Pay endpoint.
61+
*/
62+
private fun walletEnvFromConfig(configJson: String): Int {
63+
return try {
64+
if (JSONObject(configJson).optString("googlePayEnvironment") == "PRODUCTION")
65+
WalletConstants.ENVIRONMENT_PRODUCTION
66+
else
67+
WalletConstants.ENVIRONMENT_TEST
68+
} catch (e: Exception) {
69+
WalletConstants.ENVIRONMENT_TEST
5070
}
51-
return paymentsClient!!
71+
}
72+
73+
override fun invalidate() {
74+
reactApplicationContext.removeActivityEventListener(this)
75+
super.invalidate()
5276
}
5377

5478
@ReactMethod
5579
fun isReadyToPay(configJson: String, promise: Promise) {
80+
val activity = reactApplicationContext.currentActivity
81+
if (activity == null) {
82+
promise.resolve(false)
83+
return
84+
}
5685
try {
5786
val isReadyToPayRequest = IsReadyToPayRequest.fromJson(buildIsReadyToPayRequest().toString())
58-
getPaymentsClient().isReadyToPay(isReadyToPayRequest)
87+
getPaymentsClient(activity, walletEnvFromConfig(configJson)).isReadyToPay(isReadyToPayRequest)
5988
.addOnCompleteListener { task ->
6089
promise.resolve(task.isSuccessful && task.result == true)
6190
}
@@ -77,12 +106,15 @@ class GooglePayModule(reactContext: ReactApplicationContext) :
77106

78107
val activity = reactApplicationContext.currentActivity
79108
if (activity == null) {
109+
pendingPromise = null
110+
pendingPublishableKey = ""
111+
pendingBaseUrl = ""
80112
promise.reject("NO_ACTIVITY", "No current activity")
81113
return
82114
}
83115

84116
AutoResolveHelper.resolveTask(
85-
getPaymentsClient().loadPaymentData(request),
117+
getPaymentsClient(activity, walletEnvFromConfig(configJson)).loadPaymentData(request),
86118
activity,
87119
LOAD_PAYMENT_DATA_REQUEST_CODE
88120
)
@@ -91,11 +123,20 @@ class GooglePayModule(reactContext: ReactApplicationContext) :
91123
}
92124
}
93125

126+
// ActivityEventListener — receives onActivityResult forwarded by React Native
127+
override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) {
128+
if (requestCode == LOAD_PAYMENT_DATA_REQUEST_CODE) {
129+
val paymentData = data?.let { PaymentData.getFromIntent(it) }
130+
handlePaymentResult(resultCode, paymentData)
131+
}
132+
}
133+
134+
override fun onNewIntent(intent: Intent) {}
135+
94136
/**
95-
* Called from the Activity's onActivityResult.
96137
* Processes the Google Pay payment data and tokenizes via Bolt.
97138
*/
98-
fun handlePaymentResult(resultCode: Int, paymentData: PaymentData?) {
139+
private fun handlePaymentResult(resultCode: Int, paymentData: PaymentData?) {
99140
val promise = pendingPromise ?: return
100141
pendingPromise = null
101142

src/__tests__/CreditCard.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ describe('Bolt', () => {
7373
bolt.configureOnPageStyles(newStyles);
7474
expect(bolt.getOnPageStyles()).toEqual(newStyles);
7575
});
76+
77+
it('should return X-Publishable-Key header from apiHeaders()', () => {
78+
const bolt = new Bolt({ publishableKey: 'pk_test_abc' });
79+
expect(bolt.apiHeaders()).toEqual({ 'X-Publishable-Key': 'pk_test_abc' });
80+
});
7681
});
7782

7883
describe('CreditCard', () => {

src/__tests__/GoogleWallet.test.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Platform } from 'react-native';
22
import type { GooglePayConfig, GooglePayButtonType } from '../payments/types';
3+
import { fetchGooglePayAPMConfig } from '../payments/googlePayApi';
34

45
/**
56
* Tests for the GoogleWallet component logic.
@@ -34,8 +35,10 @@ jest.mock('../native/NativeGooglePayButton', () => ({
3435
jest.mock('../client/useBolt', () => ({
3536
useBolt: () => ({
3637
publishableKey: 'pk_test_123',
38+
environment: 'production' as const,
3739
baseUrl: 'https://connect.bolt.com',
3840
apiUrl: 'https://api.bolt.com',
41+
apiHeaders: () => ({ 'X-Publishable-Key': 'pk_test_123' }),
3942
}),
4043
}));
4144

@@ -199,3 +202,45 @@ describe('GoogleWallet', () => {
199202
});
200203
});
201204
});
205+
206+
describe('fetchGooglePayAPMConfig', () => {
207+
let mockFetch: jest.Mock;
208+
209+
beforeEach(() => {
210+
mockFetch = jest.fn();
211+
global.fetch = mockFetch;
212+
});
213+
214+
it('sends the provided headers to the APM config endpoint', async () => {
215+
mockFetch.mockResolvedValue({
216+
ok: true,
217+
json: () => Promise.resolve(mockAPMConfig),
218+
});
219+
220+
await fetchGooglePayAPMConfig('https://api.bolt.com', {
221+
'X-Publishable-Key': 'pk_test_abc',
222+
});
223+
224+
expect(mockFetch).toHaveBeenCalledWith(
225+
'https://api.bolt.com/v1/apm_config/googlepay',
226+
expect.objectContaining({
227+
method: 'GET',
228+
headers: { 'X-Publishable-Key': 'pk_test_abc' },
229+
})
230+
);
231+
});
232+
233+
it('throws when the response is not ok', async () => {
234+
mockFetch.mockResolvedValue({
235+
ok: false,
236+
status: 401,
237+
statusText: 'Unauthorized',
238+
});
239+
240+
await expect(
241+
fetchGooglePayAPMConfig('https://api.bolt.com', {
242+
'X-Publishable-Key': 'bad_key',
243+
})
244+
).rejects.toThrow('Failed to fetch Google Pay config: 401 Unauthorized');
245+
});
246+
});

src/client/Bolt.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const API_URLS: Record<string, string> = {
2222

2323
export class Bolt {
2424
public readonly publishableKey: string;
25+
public readonly environment: 'production' | 'sandbox' | 'staging';
2526
public readonly baseUrl: string;
2627
public readonly apiUrl: string;
2728
public readonly language: string;
@@ -34,6 +35,7 @@ export class Bolt {
3435

3536
const env = config.environment ?? 'production';
3637
this.publishableKey = config.publishableKey;
38+
this.environment = env;
3739
this.baseUrl = ENVIRONMENT_URLS[env] ?? ENVIRONMENT_URLS.production!;
3840
this.apiUrl = API_URLS[env] ?? API_URLS.production!;
3941
this.language = config.language ?? 'en';
@@ -52,4 +54,14 @@ export class Bolt {
5254
getOnPageStyles(): Styles | undefined {
5355
return this.onPageStyles;
5456
}
57+
58+
/**
59+
* Returns the standard HTTP headers required for Bolt REST API calls.
60+
* Centralised here so every API caller uses a consistent header name.
61+
*/
62+
apiHeaders(): Record<string, string> {
63+
return {
64+
'X-Publishable-Key': this.publishableKey,
65+
};
66+
}
5567
}

src/payments/GoogleWallet.tsx

Lines changed: 14 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import type {
1212
} from './types';
1313
import { startSpan, SpanStatusCode } from '../telemetry/tracer';
1414
import { BoltAttributes } from '../telemetry/attributes';
15+
import { fetchGooglePayAPMConfig } from './googlePayApi';
16+
17+
export { fetchGooglePayAPMConfig };
1518

1619
// Conditional require: Metro inlines Platform.OS and eliminates the dead branch at bundle
1720
// time, so NativeGooglePayButton (which calls codegenNativeComponent) is never loaded on
@@ -32,31 +35,6 @@ export interface GoogleWalletProps {
3235
borderRadius?: number;
3336
}
3437

35-
/**
36-
* Fetch Google Pay configuration from Bolt's API.
37-
* The config includes tokenization spec, merchant ID, and merchant name
38-
* so the developer doesn't need to provide them.
39-
*/
40-
const fetchGooglePayAPMConfig = async (
41-
apiUrl: string,
42-
publishableKey: string
43-
): Promise<GooglePayAPMConfigResponse> => {
44-
const response = await fetch(`${apiUrl}/v1/apm_config/googlepay`, {
45-
method: 'GET',
46-
headers: {
47-
merchant_token: publishableKey,
48-
},
49-
});
50-
51-
if (!response.ok) {
52-
throw new Error(
53-
`Failed to fetch Google Pay config: ${response.status} ${response.statusText}`
54-
);
55-
}
56-
57-
return response.json();
58-
};
59-
6038
/**
6139
* <GoogleWallet /> — renders a native Google Pay button that triggers the
6240
* native PaymentsClient payment sheet via the BoltGooglePay TurboModule.
@@ -82,7 +60,7 @@ export const GoogleWallet = ({
8260
useEffect(() => {
8361
if (Platform.OS !== 'android') return;
8462

85-
fetchGooglePayAPMConfig(bolt.apiUrl, bolt.publishableKey)
63+
fetchGooglePayAPMConfig(bolt.apiUrl, bolt.apiHeaders())
8664
.then(setApmConfigResponse)
8765
.catch((err) => {
8866
onError?.(
@@ -91,7 +69,7 @@ export const GoogleWallet = ({
9169
: new Error('Failed to fetch Google Pay config')
9270
);
9371
});
94-
}, [bolt.apiUrl, bolt.publishableKey, onError]);
72+
}, [bolt, onError]);
9573

9674
// Check Google Pay readiness once we have the APM config
9775
useEffect(() => {
@@ -102,12 +80,13 @@ export const GoogleWallet = ({
10280

10381
const nativeConfig = buildNativeConfig(
10482
config,
105-
apmConfigResponse.bolt_config
83+
apmConfigResponse.bolt_config,
84+
bolt.environment
10685
);
10786
NativeGooglePay.isReadyToPay(JSON.stringify(nativeConfig))
10887
.then(setAvailable)
10988
.catch(() => setAvailable(false));
110-
}, [config, apmConfigResponse]);
89+
}, [config, apmConfigResponse, bolt.environment]);
11190

11291
const handlePress = useCallback(async () => {
11392
if (!NativeGooglePay || !apmConfigResponse) {
@@ -123,7 +102,8 @@ export const GoogleWallet = ({
123102
try {
124103
const nativeConfig = buildNativeConfig(
125104
config,
126-
apmConfigResponse.bolt_config
105+
apmConfigResponse.bolt_config,
106+
bolt.environment
127107
);
128108
const resultJson = await NativeGooglePay.requestPayment(
129109
JSON.stringify(nativeConfig),
@@ -166,7 +146,8 @@ export const GoogleWallet = ({
166146
*/
167147
const buildNativeConfig = (
168148
config: GooglePayConfig,
169-
apmConfig: GooglePayAPMConfig
149+
apmConfig: GooglePayAPMConfig,
150+
environment: 'production' | 'sandbox' | 'staging'
170151
) => {
171152
return {
172153
// From Bolt API
@@ -181,5 +162,7 @@ const buildNativeConfig = (
181162
totalPriceLabel: config.label,
182163
billingAddressFormat:
183164
config.billingAddressCollectionFormat === 'none' ? 'NONE' : 'FULL',
165+
// Tells the native module which Google Pay environment to use
166+
googlePayEnvironment: environment === 'production' ? 'PRODUCTION' : 'TEST',
184167
};
185168
};

src/payments/googlePayApi.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { GooglePayAPMConfigResponse } from './types';
2+
3+
/**
4+
* Fetch Google Pay configuration from Bolt's API.
5+
* The config includes tokenization spec, merchant ID, and merchant name
6+
* so the developer doesn't need to provide them.
7+
*
8+
* Extracted into its own module so it can be imported and tested independently
9+
* of the platform-specific GoogleWallet component files.
10+
*/
11+
export const fetchGooglePayAPMConfig = async (
12+
apiUrl: string,
13+
headers: Record<string, string>
14+
): Promise<GooglePayAPMConfigResponse> => {
15+
const response = await fetch(`${apiUrl}/v1/apm_config/googlepay`, {
16+
method: 'GET',
17+
headers,
18+
});
19+
20+
if (!response.ok) {
21+
throw new Error(
22+
`Failed to fetch Google Pay config: ${response.status} ${response.statusText}`
23+
);
24+
}
25+
26+
return response.json();
27+
};

0 commit comments

Comments
 (0)