Skip to content

NullPointerException in queryProductDetails callback after release() (v1.1.8) #80

Description

@digitalapps21

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

  1. Create a BillingConnector with a lifecycle.
  2. Configure product IDs and install a BillingEventListener.
  3. Call connect(), causing product-details queries to begin.
  4. Destroy the lifecycle owner while a query is still in flight.
  5. The lifecycle callback invokes BillingConnector.release() and clears the listener.
  6. The product query completes and posts its listener callback afterward.
  7. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions