BillingConnector` 1.1.8 can crash when a product-details query completes while the connector is being released.
The asynchronous query callback posts a runnable that directly dereferences billingEventListener:
|
findUiHandler().post(() -> billingEventListener.onProductsFetched(fetchedProductInfo)); |
findUiHandler().post(() -> billingEventListener.onProductsFetched(fetchedProductInfo));
However, release() removes currently queued handler callbacks and then sets billingEventListener to null:
|
public void release() { |
|
if (billingClient != null && billingClient.isReady()) { |
|
Log("BillingConnector instance release: ending connection..."); |
|
billingClient.endConnection(); |
|
} |
|
|
|
// Prevent memory leaks and NPEs from pending exponential backoff tasks after the lifecycle is destroyed |
|
uiHandler.removeCallbacksAndMessages(null); |
|
|
|
billingEventListener = null; |
public void release() {
if (billingClient != null && billingClient.isReady()) {
Log("BillingConnector instance release: ending connection...");
billingClient.endConnection();
}
uiHandler.removeCallbacksAndMessages(null);
billingEventListener = null;
}
A Play Billing callback can race with release() and post a new runnable after removeCallbacksAndMessages(null) has already executed. When the runnable executes on the main thread, it dereferences the cleared listener.
Crash
Fatal Exception: java.lang.NullPointerException:
Attempt to invoke interface method
'void io.c(java.util.ArrayList)' on a null object reference
at games.moisoni.google_iab.BillingConnector.lambda$queryProductDetails$15(BillingConnector.java:482)
at android.os.Handler.handleCallback(Handler.java:959)
at android.os.Handler.dispatchMessage(Handler.java:100)
at android.os.Looper.loopOnce(Looper.java:232)
at android.os.Looper.loop(Looper.java:317)
at android.app.ActivityThread.main(ActivityThread.java:8842)
at java.lang.reflect.Method.invoke(Method.java)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:681)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:902)
The listener interface and method names are obfuscated in the release build, but the mapped location is BillingConnector.java:482, inside queryProductDetails().
Library version
implementation 'com.github.moisoni97:google-inapp-billing:1.1.8'
Likely reproduction
- Create a
BillingConnector with a lifecycle.
- Configure product IDs and install a
BillingEventListener.
- Call
connect(), causing product-details queries to begin.
- Destroy the lifecycle owner while a query is still in flight.
- The lifecycle callback invokes
BillingConnector.release() and clears the listener.
- The product query completes and posts its listener callback afterward.
- The posted runnable calls
onProductsFetched() on the null listener.
The crash is timing-dependent and intermittent.
Expected behavior
Late BillingClient callbacks are discarded safely after the connector has been released.
Actual behavior
A late callback dereferences the null billingEventListener and crashes the application.
Proposed upstream fix
Minimal fix for the reported crash
Resolve the listener inside the posted runnable and check it before invocation:
-findUiHandler().post(() -> billingEventListener.onProductsFetched(fetchedProductInfo));
+findUiHandler().post(() -> {
+ BillingEventListener listener = billingEventListener;
+ if (listener != null) {
+ listener.onProductsFetched(fetchedProductInfo);
+ }
+});
This prevents the reported onProductsFetched() NPE. Other asynchronous callbacks still access the same mutable listener directly, however, so a centralized fix is preferable.
Recommended comprehensive fix
Add a released state and route every asynchronous BillingEventListener invocation through one null-safe dispatcher:
private volatile boolean released;
private interface BillingEventAction {
void dispatch(@NonNull BillingEventListener listener);
}
private void postBillingEvent(@NonNull BillingEventAction action) {
if (released) {
return;
}
findUiHandler().post(() -> {
if (released) {
return;
}
BillingEventListener listener = billingEventListener;
if (listener != null) {
action.dispatch(listener);
}
});
}
Replace the reported callback with:
postBillingEvent(listener -> listener.onProductsFetched(fetchedProductInfo));
Use the same dispatcher for error and purchase callbacks, for example:
postBillingEvent(listener -> listener.onBillingError(
BillingConnector.this,
new BillingResponse(ErrorType.BILLING_ERROR, billingResult)));
postBillingEvent(listener -> listener.onProductsPurchased(purchaseInfoList));
postBillingEvent(listener -> listener.onPurchaseAcknowledged(purchaseInfo));
postBillingEvent(listener -> listener.onPurchaseConsumed(purchaseInfo));
Mark the connector as released before ending the connection or clearing queued work:
public void release() {
+ released = true;
if (billingClient != null && billingClient.isReady()) {
Log("BillingConnector instance release: ending connection...");
billingClient.endConnection();
}
uiHandler.removeCallbacksAndMessages(null);
billingEventListener = null;
}
Setting released first closes the window in which a concurrent BillingClient callback can enqueue new listener work during teardown. Checking both before posting and inside the runnable covers callbacks that race with release() at either point.
Every direct asynchronous access to billingEventListener should use this dispatcher. Fixing only onProductsFetched() may leave equivalent teardown races in error, purchase, acknowledgement, or consumption callbacks.
Application-side workaround
Until the library is fixed, do not give the lifecycle directly to BillingConnector. Observe the lifecycle in application code and perform guarded cleanup instead.
Construct the connector with a null lifecycle:
BillingConnector connector = new BillingConnector(context, licenseKey, null)
.setSubscriptionIds(subscriptionIds)
.autoAcknowledge();
Install a process-wide no-op listener before release, call release(), and restore the no-op listener afterward because version 1.1.8 clears it:
private static final BillingEventListener RELEASED_LISTENER =
new NoOpBillingEventListener();
public static void releaseSafely(@Nullable BillingConnector connector) {
if (connector == null) {
return;
}
connector.setBillingEventListener(RELEASED_LISTENER);
try {
connector.release();
} finally {
connector.setBillingEventListener(RELEASED_LISTENER);
}
}
The no-op listener must not capture an Activity, Fragment, view, or other short-lived object. It should be process-wide so late callbacks cannot leak a destroyed screen.
This workaround prevents the null dereference, but the library should discard callbacks internally once it has been released.
BillingConnector` 1.1.8 can crash when a product-details query completes while the connector is being released.
The asynchronous query callback posts a runnable that directly dereferences
billingEventListener:google-inapp-billing/google-iab/src/main/java/games/moisoni/google_iab/BillingConnector.java
Line 482 in 3eccafc
However,
release()removes currently queued handler callbacks and then setsbillingEventListenertonull:google-inapp-billing/google-iab/src/main/java/games/moisoni/google_iab/BillingConnector.java
Lines 1428 to 1437 in 3eccafc
A Play Billing callback can race with
release()and post a new runnable afterremoveCallbacksAndMessages(null)has already executed. When the runnable executes on the main thread, it dereferences the cleared listener.Crash
The listener interface and method names are obfuscated in the release build, but the mapped location is
BillingConnector.java:482, insidequeryProductDetails().Library version
implementation 'com.github.moisoni97:google-inapp-billing:1.1.8'Likely reproduction
BillingConnectorwith a lifecycle.BillingEventListener.connect(), causing product-details queries to begin.BillingConnector.release()and clears the listener.onProductsFetched()on the null listener.The crash is timing-dependent and intermittent.
Expected behavior
Late BillingClient callbacks are discarded safely after the connector has been released.
Actual behavior
A late callback dereferences the null
billingEventListenerand crashes the application.Proposed upstream fix
Minimal fix for the reported crash
Resolve the listener inside the posted runnable and check it before invocation:
This prevents the reported
onProductsFetched()NPE. Other asynchronous callbacks still access the same mutable listener directly, however, so a centralized fix is preferable.Recommended comprehensive fix
Add a released state and route every asynchronous
BillingEventListenerinvocation through one null-safe dispatcher:Replace the reported callback with:
Use the same dispatcher for error and purchase callbacks, for example:
Mark the connector as released before ending the connection or clearing queued work:
public void release() { + released = true; if (billingClient != null && billingClient.isReady()) { Log("BillingConnector instance release: ending connection..."); billingClient.endConnection(); } uiHandler.removeCallbacksAndMessages(null); billingEventListener = null; }Setting
releasedfirst closes the window in which a concurrent BillingClient callback can enqueue new listener work during teardown. Checking both before posting and inside the runnable covers callbacks that race withrelease()at either point.Every direct asynchronous access to
billingEventListenershould use this dispatcher. Fixing onlyonProductsFetched()may leave equivalent teardown races in error, purchase, acknowledgement, or consumption callbacks.Application-side workaround
Until the library is fixed, do not give the lifecycle directly to
BillingConnector. Observe the lifecycle in application code and perform guarded cleanup instead.Construct the connector with a null lifecycle:
Install a process-wide no-op listener before release, call
release(), and restore the no-op listener afterward because version 1.1.8 clears it:The no-op listener must not capture an
Activity,Fragment, view, or other short-lived object. It should be process-wide so late callbacks cannot leak a destroyed screen.This workaround prevents the null dereference, but the library should discard callbacks internally once it has been released.