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
- Play services updates mid-session →
onBillingServiceDisconnected nulls the module's client.
- App calls
reconnect() → resolves true, connected stays true.
- Next
fetchProducts / requestPurchase → billingClient ?: throw OpenIapError.NotPrepared (OpenIapModule.kt:861, :1027, :1124, and others) → JS sees E_NOT_PREPARED while the hook still reports connected.
- 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.
When Google Play's billing service drops mid-session, react-native-iap's Android bridge never finds out.
useIAP'sconnectedstaystrue, every subsequent call fails withE_NOT_PREPARED, andreconnect()— the documented recovery — resolvestruewithout 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/googlehandles the disconnect correctly.OpenIapModule.kt:643(Play) and:592(Horizon) both implementonBillingServiceDisconnected, bumpconnectionGeneration, null the client viareplaceBillingClientLocked(null), and fail in-flight operations withServiceDisconnected.What's missing is a way to tell the bridge.
OpenIapProtocol.ktdeclares 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:186Its only writes are a listener-attach failure during init (:361),
onCommitted(:416),onFailed(:419), andendConnectioncleanup (:440). None is reachable from a service drop.Why recovery fails
HybridRnIap.kt:226, insideinitConnection:After a drop the flag is still
true, soinitConnectionreturns success without ever callingopenIap.initConnection(...).useIAP.reconnect()(src/hooks/useIAP.ts:759) calls exactly that, getstrue, setsconnected = true, and returnstrue. Nothing was rebuilt.The native module would in fact rebuild if it were reached —
OpenIapModule.kt:537if (billingClient?.isReady == true) return@withContext trueis false once the client is null, so a fresh connection attempt would start. The bridge just never lets the call through.Failure sequence
onBillingServiceDisconnectednulls the module's client.reconnect()→ resolvestrue,connectedstaystrue.fetchProducts/requestPurchase→billingClient ?: throw OpenIapError.NotPrepared(OpenIapModule.kt:861,:1027,:1124, and others) → JS seesE_NOT_PREPAREDwhile the hook still reports connected.endConnection()(:440, the one remaining clear site) followed byinitConnection()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
OpenIapProtocola connection-state listener and have the bridge clearisInitializedon disconnect, soreconnect()reaches the native rebuild andconnectedstops lying. A cheaper stopgap — dropping theif (isInitialized)short-circuit soinitConnectionis always forwarded — would fixreconnect()but leavesconnectedwrong until something fails.Note
isInitializedis a plain field read from coroutine dispatchers without synchronization; the iOS bridge reads its equivalent underlistenerLock. Worth addressing in the same pass.