Skip to content

Android: react-native-iap never learns about a billing service disconnect, and reconnect() cannot recover #408

Description

@hyochan

When Google Play's billing service drops mid-session, react-native-iap's Android bridge never finds out. useIAP's connected stays true, every subsequent call fails with E_NOT_PREPARED, and reconnect() — the documented recovery — resolves true without doing anything. The app cannot recover for the rest of the session.

expo-iap is not affected; it keeps no bridge-side connection flag. This is specific to the react-native-iap bridge.

Cause

packages/google handles the disconnect correctly. OpenIapModule.kt:643 (Play) and :592 (Horizon) both implement onBillingServiceDisconnected, bump connectionGeneration, null the client via replaceBillingClientLocked(null), and fail in-flight operations with ServiceDisconnected.

What's missing is a way to tell the bridge. OpenIapProtocol.kt declares the full listener surface — purchase update, purchase error, user-choice billing, developer-provided billing, subscription billing issue — and none of them is a connection-state listener. So the bridge's own mirror of the connection state is never invalidated:

libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt:186

private var isInitialized = false

Its only writes are a listener-attach failure during init (:361), onCommitted (:416), onFailed (:419), and endConnection cleanup (:440). None is reachable from a service drop.

Why recovery fails

HybridRnIap.kt:226, inside initConnection:

if (isInitialized) return@initOperation true

After a drop the flag is still true, so initConnection returns success without ever calling openIap.initConnection(...). useIAP.reconnect() (src/hooks/useIAP.ts:759) calls exactly that, gets true, sets connected = true, and returns true. Nothing was rebuilt.

The native module would in fact rebuild if it were reached — OpenIapModule.kt:537 if (billingClient?.isReady == true) return@withContext true is false once the client is null, so a fresh connection attempt would start. The bridge just never lets the call through.

Failure sequence

  1. Play services updates mid-session → onBillingServiceDisconnected nulls the module's client.
  2. App calls reconnect() → resolves true, connected stays true.
  3. Next fetchProducts / requestPurchasebillingClient ?: throw OpenIapError.NotPrepared (OpenIapModule.kt:861, :1027, :1124, and others) → JS sees E_NOT_PREPARED while the hook still reports connected.
  4. Only an explicit endConnection() (:440, the one remaining clear site) followed by initConnection() restores it — and nothing in the docs tells an app to do that.

Related

Nulling the module's client reference on disconnect also defeats the Play Billing 8.0+ auto-reconnect enabled at OpenIapModule.kt:2730 (enableAutoServiceReconnectionIfAvailable): the auto-retrying client instance is no longer the one the module holds. Worth deciding whether the handler should keep the reference when auto-reconnect is active.

Suggested direction

Give OpenIapProtocol a connection-state listener and have the bridge clear isInitialized on disconnect, so reconnect() reaches the native rebuild and connected stops lying. A cheaper stopgap — dropping the if (isInitialized) short-circuit so initConnection is always forwarded — would fix reconnect() but leaves connected wrong until something fails.

Note isInitialized is a plain field read from coroutine dispatchers without synchronization; the iOS bridge reads its equivalent under listenerLock. Worth addressing in the same pass.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions