react-native-revnix 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ apply plugin: "com.android.library"
2
+ apply plugin: "org.jetbrains.kotlin.android"
3
+
4
+ def safeExtGet(prop, fallback) {
5
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
6
+ }
7
+
8
+ android {
9
+ namespace "com.revnix.reactnative"
10
+ compileSdkVersion safeExtGet("compileSdkVersion", 35)
11
+
12
+ defaultConfig {
13
+ minSdkVersion safeExtGet("minSdkVersion", 24)
14
+ }
15
+
16
+ compileOptions {
17
+ sourceCompatibility JavaVersion.VERSION_17
18
+ targetCompatibility JavaVersion.VERSION_17
19
+ }
20
+
21
+ kotlinOptions {
22
+ jvmTarget = "17"
23
+ }
24
+ }
25
+
26
+ repositories {
27
+ google()
28
+ mavenCentral()
29
+ }
30
+
31
+ dependencies {
32
+ implementation "com.facebook.react:react-native:+"
33
+ implementation "com.android.billingclient:billing:8.3.0"
34
+ }
@@ -0,0 +1 @@
1
+ <manifest />
@@ -0,0 +1,247 @@
1
+ package com.revnix.reactnative
2
+
3
+ import android.util.Log
4
+ import com.android.billingclient.api.AcknowledgePurchaseParams
5
+ import com.android.billingclient.api.BillingClient
6
+ import com.android.billingclient.api.BillingClient.BillingResponseCode
7
+ import com.android.billingclient.api.BillingClient.ProductType
8
+ import com.android.billingclient.api.BillingClientStateListener
9
+ import com.android.billingclient.api.BillingFlowParams
10
+ import com.android.billingclient.api.BillingResult
11
+ import com.android.billingclient.api.PendingPurchasesParams
12
+ import com.android.billingclient.api.ProductDetails
13
+ import com.android.billingclient.api.Purchase
14
+ import com.android.billingclient.api.PurchasesUpdatedListener
15
+ import com.android.billingclient.api.QueryProductDetailsParams
16
+ import com.android.billingclient.api.QueryPurchasesParams
17
+ import com.facebook.react.bridge.Arguments
18
+ import com.facebook.react.bridge.Promise
19
+ import com.facebook.react.bridge.ReactApplicationContext
20
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
21
+ import com.facebook.react.bridge.ReactMethod
22
+ import com.facebook.react.bridge.WritableMap
23
+ import com.facebook.react.modules.core.DeviceEventManagerModule
24
+ import java.util.concurrent.ConcurrentHashMap
25
+ import java.util.concurrent.atomic.AtomicReference
26
+
27
+ class RevnixStoreModule(context: ReactApplicationContext) :
28
+ ReactContextBaseJavaModule(context), PurchasesUpdatedListener {
29
+
30
+ private class PendingPurchase(val productId: String, val promise: Promise)
31
+
32
+ private val pending = AtomicReference<PendingPurchase?>(null)
33
+ private val acknowledged = ConcurrentHashMap.newKeySet<String>()
34
+ private val waitingForSetup = mutableListOf<(BillingResult) -> Unit>()
35
+
36
+ private val billing: BillingClient = BillingClient.newBuilder(context)
37
+ .setListener(this)
38
+ .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
39
+ .enableAutoServiceReconnection()
40
+ .build()
41
+
42
+ override fun getName() = "RevnixStore"
43
+
44
+ override fun invalidate() {
45
+ billing.endConnection()
46
+ }
47
+
48
+ @ReactMethod
49
+ fun addListener(eventName: String) {
50
+ withBilling {}
51
+ }
52
+
53
+ @ReactMethod
54
+ fun removeListeners(count: Int) {}
55
+
56
+ @ReactMethod
57
+ fun purchase(productId: String, customerId: String, promise: Promise) {
58
+ withBilling { setup ->
59
+ if (setup.responseCode != BillingResponseCode.OK) {
60
+ promise.reject("store_error", setup.debugMessage)
61
+ return@withBilling
62
+ }
63
+ queryProduct(productId, ProductType.SUBS) { subs ->
64
+ if (subs != null) {
65
+ launch(subs, customerId, promise)
66
+ return@queryProduct
67
+ }
68
+ queryProduct(productId, ProductType.INAPP) { inapp ->
69
+ if (inapp != null) launch(inapp, customerId, promise)
70
+ else promise.reject("product_not_found", "No Play product with id $productId")
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ @ReactMethod
77
+ fun restore(promise: Promise) {
78
+ withBilling { setup ->
79
+ if (setup.responseCode != BillingResponseCode.OK) {
80
+ promise.reject("store_error", setup.debugMessage)
81
+ return@withBilling
82
+ }
83
+ val restored = Arguments.createArray()
84
+ queryPurchases(ProductType.SUBS) { subs ->
85
+ subs.forEach { restored.pushMap(toMap(it)) }
86
+ queryPurchases(ProductType.INAPP) { inapp ->
87
+ inapp.forEach { restored.pushMap(toMap(it)) }
88
+ promise.resolve(restored)
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ // ponytail: acknowledges every purchase; consumable INAPP products need consumeAsync to be bought again
95
+ @ReactMethod
96
+ fun finish(purchaseToken: String, promise: Promise) {
97
+ if (purchaseToken in acknowledged) {
98
+ promise.resolve(null)
99
+ return
100
+ }
101
+ withBilling { setup ->
102
+ if (setup.responseCode != BillingResponseCode.OK) {
103
+ promise.reject("store_error", "${setup.responseCode} ${setup.debugMessage}")
104
+ return@withBilling
105
+ }
106
+ val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchaseToken).build()
107
+ billing.acknowledgePurchase(params) { result ->
108
+ if (result.responseCode == BillingResponseCode.OK) {
109
+ acknowledged.add(purchaseToken)
110
+ promise.resolve(null)
111
+ } else {
112
+ promise.reject("store_error", "acknowledgePurchase failed: ${result.responseCode} ${result.debugMessage}")
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
119
+ if (result.responseCode != BillingResponseCode.OK) {
120
+ settle(pending.getAndSet(null) ?: return, result)
121
+ return
122
+ }
123
+ for (purchase in purchases.orEmpty()) {
124
+ val waiting = pending.get()
125
+ if (waiting != null && purchase.products.contains(waiting.productId) &&
126
+ pending.compareAndSet(waiting, null)
127
+ ) {
128
+ val purchased = purchase.purchaseState == Purchase.PurchaseState.PURCHASED
129
+ waiting.promise.resolve(if (purchased) toMap(purchase) else null)
130
+ } else if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
131
+ emit(toMap(purchase))
132
+ }
133
+ }
134
+ }
135
+
136
+ private fun withBilling(onReady: (BillingResult) -> Unit) {
137
+ if (billing.isReady) {
138
+ onReady(BillingResult.newBuilder().setResponseCode(BillingResponseCode.OK).build())
139
+ return
140
+ }
141
+ synchronized(waitingForSetup) {
142
+ waitingForSetup.add(onReady)
143
+ if (waitingForSetup.size > 1) return
144
+ }
145
+ billing.startConnection(object : BillingClientStateListener {
146
+ override fun onBillingSetupFinished(result: BillingResult) {
147
+ val ready = synchronized(waitingForSetup) {
148
+ waitingForSetup.toList().also { waitingForSetup.clear() }
149
+ }
150
+ if (result.responseCode == BillingResponseCode.OK) {
151
+ queryPurchases(ProductType.SUBS) { subs ->
152
+ queryPurchases(ProductType.INAPP) { inapp ->
153
+ (subs + inapp).filterNot { it.isAcknowledged }.forEach { emit(toMap(it)) }
154
+ }
155
+ }
156
+ }
157
+ ready.forEach { it(result) }
158
+ }
159
+
160
+ override fun onBillingServiceDisconnected() {}
161
+ })
162
+ }
163
+
164
+ private fun settle(waiting: PendingPurchase, result: BillingResult) {
165
+ when (result.responseCode) {
166
+ BillingResponseCode.USER_CANCELED -> waiting.promise.resolve(null)
167
+ BillingResponseCode.ITEM_ALREADY_OWNED -> queryPurchases(ProductType.SUBS) { subs ->
168
+ queryPurchases(ProductType.INAPP) { inapp ->
169
+ val owned = (subs + inapp).firstOrNull { waiting.productId in it.products }
170
+ if (owned != null) waiting.promise.resolve(toMap(owned))
171
+ else waiting.promise.reject("store_error", "${result.responseCode} ${result.debugMessage}")
172
+ }
173
+ }
174
+ else -> waiting.promise.reject("store_error", "${result.responseCode} ${result.debugMessage}")
175
+ }
176
+ }
177
+
178
+ private fun queryProduct(productId: String, type: String, then: (ProductDetails?) -> Unit) {
179
+ val product = QueryProductDetailsParams.Product.newBuilder()
180
+ .setProductId(productId)
181
+ .setProductType(type)
182
+ .build()
183
+ val params = QueryProductDetailsParams.newBuilder().setProductList(listOf(product)).build()
184
+ billing.queryProductDetailsAsync(params) { _, details ->
185
+ then(details.productDetailsList.firstOrNull())
186
+ }
187
+ }
188
+
189
+ private fun queryPurchases(type: String, then: (List<Purchase>) -> Unit) {
190
+ val params = QueryPurchasesParams.newBuilder().setProductType(type).build()
191
+ billing.queryPurchasesAsync(params) { result, purchases ->
192
+ if (result.responseCode != BillingResponseCode.OK) {
193
+ Log.w(TAG, "queryPurchases($type) failed: ${result.responseCode} ${result.debugMessage}")
194
+ }
195
+ then(purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED })
196
+ }
197
+ }
198
+
199
+ private fun launch(details: ProductDetails, customerId: String, promise: Promise) {
200
+ val activity = reactApplicationContext.currentActivity
201
+ if (activity == null) {
202
+ promise.reject("no_activity", "No foreground Activity to show Google Play billing")
203
+ return
204
+ }
205
+ val productParams = BillingFlowParams.ProductDetailsParams.newBuilder()
206
+ .setProductDetails(details)
207
+ .apply {
208
+ // ponytail: first base plan/offer wins; products with several base plans need a basePlanId/offerId selector passed through purchase()
209
+ details.subscriptionOfferDetails?.firstOrNull()?.let { setOfferToken(it.offerToken) }
210
+ }
211
+ .build()
212
+ val params = BillingFlowParams.newBuilder()
213
+ .setProductDetailsParamsList(listOf(productParams))
214
+ .setObfuscatedAccountId(customerId)
215
+ .build()
216
+ activity.runOnUiThread {
217
+ val waiting = PendingPurchase(details.productId, promise)
218
+ pending.getAndSet(waiting)?.promise?.resolve(null)
219
+ val result = billing.launchBillingFlow(activity, params)
220
+ if (result.responseCode != BillingResponseCode.OK && pending.compareAndSet(waiting, null)) {
221
+ settle(waiting, result)
222
+ }
223
+ }
224
+ }
225
+
226
+ private fun toMap(purchase: Purchase): WritableMap {
227
+ if (purchase.isAcknowledged) acknowledged.add(purchase.purchaseToken)
228
+ return Arguments.createMap().apply {
229
+ putString("store", "google")
230
+ putString("productId", purchase.products.firstOrNull())
231
+ putString("transactionId", purchase.purchaseToken)
232
+ putString("token", purchase.purchaseToken)
233
+ putDouble("occurredAt", purchase.purchaseTime.toDouble())
234
+ }
235
+ }
236
+
237
+ private fun emit(transaction: WritableMap) {
238
+ if (!reactApplicationContext.hasActiveReactInstance()) return
239
+ reactApplicationContext
240
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
241
+ .emit("revnixStoreTransaction", transaction)
242
+ }
243
+
244
+ private companion object {
245
+ const val TAG = "RevnixStore"
246
+ }
247
+ }
@@ -0,0 +1,14 @@
1
+ package com.revnix.reactnative
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ class RevnixStorePackage : ReactPackage {
9
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
10
+ listOf(RevnixStoreModule(reactContext))
11
+
12
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
13
+ emptyList()
14
+ }
package/dist/client.d.ts CHANGED
@@ -33,6 +33,7 @@ export declare class RevnixClient {
33
33
  private implicitConfigPromise;
34
34
  private implicitConfigRetryAt;
35
35
  private lifecycleUnsubscribe;
36
+ private stopStoreObserver;
36
37
  /** The cold-start batch while it runs, resolving to whether it presented a
37
38
  * paywall. A deep link delivered on the first frame — SwiftUI's onOpenURL,
38
39
  * RN's getInitialURL — must wait for it, or the customer gets the launch
@@ -90,6 +91,17 @@ export declare class RevnixClient {
90
91
  * PurchaseBlockedError when the token belongs to another customer and the
91
92
  * app's transfer behavior forbids reassignment. */
92
93
  registerPurchase(input: RegisterPurchaseInput): Promise<RegisterPurchaseResult>;
94
+ /** Buys `productId` through the native store (StoreKit 2 / Play Billing),
95
+ * registers it with Revnix, then finishes the store transaction. Resolves
96
+ * null when the customer cancelled or the purchase is pending approval.
97
+ * Needs `import "react-native-revnix/store"` before configure(). A refused
98
+ * claim (PurchaseBlockedError) is never finished, so Google refunds it. */
99
+ purchase(productId: string, opts?: Pick<RegisterPurchaseInput, "viewId" | "paywallId" | "placementKey">): Promise<RegisterPurchaseResult | null>;
100
+ /** Re-registers every purchase the store still holds for this device and
101
+ * resolves how many registered. Per-purchase failures go to onDiagnostic. */
102
+ restore(): Promise<number>;
103
+ private requireStore;
104
+ private registerStoreTransaction;
93
105
  private postPurchase;
94
106
  /** C7: retry purchases that failed to register and were persisted locally.
95
107
  * Runs automatically at construction; call again when connectivity returns.
package/dist/client.js CHANGED
@@ -5,6 +5,7 @@ const errors_js_1 = require("./errors.js");
5
5
  const storage_js_1 = require("./storage.js");
6
6
  const device_js_1 = require("./device.js");
7
7
  const version_js_1 = require("./version.js");
8
+ const store_js_1 = require("./store.js");
8
9
  const implicit_js_1 = require("./implicit.js");
9
10
  const CUSTOMER_ID_KEY = "revnix.customerId";
10
11
  const SESSION_STARTED_AT_KEY = "revnix.sessionStartedAt";
@@ -75,6 +76,7 @@ class RevnixClient {
75
76
  this.implicitConfigPromise = null;
76
77
  this.implicitConfigRetryAt = 0;
77
78
  this.lifecycleUnsubscribe = null;
79
+ this.stopStoreObserver = null;
78
80
  /** The cold-start batch while it runs, resolving to whether it presented a
79
81
  * paywall. A deep link delivered on the first frame — SwiftUI's onOpenURL,
80
82
  * RN's getInitialURL — must wait for it, or the customer gets the launch
@@ -151,6 +153,10 @@ class RevnixClient {
151
153
  // C7: retry any purchases that failed to register on a previous launch, so
152
154
  // a paying user never stays locked out because a POST didn't land.
153
155
  void this.retryPendingPurchases().catch((err) => this.diag("retryPendingPurchases", err));
156
+ const store = (0, store_js_1.getStoreAdapter)();
157
+ if (store) {
158
+ this.stopStoreObserver = store.observe((tx) => void this.registerStoreTransaction(store, tx).catch((err) => this.diag("storeObserver", err)));
159
+ }
154
160
  // REV-157: warm the placement cache at launch, so the first paywall of the
155
161
  // session can render offline. Fire-and-forget; a failed key just stays on
156
162
  // whatever an earlier launch cached.
@@ -293,6 +299,66 @@ class RevnixClient {
293
299
  throw err;
294
300
  }
295
301
  }
302
+ /** Buys `productId` through the native store (StoreKit 2 / Play Billing),
303
+ * registers it with Revnix, then finishes the store transaction. Resolves
304
+ * null when the customer cancelled or the purchase is pending approval.
305
+ * Needs `import "react-native-revnix/store"` before configure(). A refused
306
+ * claim (PurchaseBlockedError) is never finished, so Google refunds it. */
307
+ async purchase(productId, opts) {
308
+ const store = this.requireStore();
309
+ const customerId = await this.customerIdPromise;
310
+ const tx = await store.purchase(productId, { customerId });
311
+ if (!tx)
312
+ return null;
313
+ return this.registerStoreTransaction(store, tx, opts);
314
+ }
315
+ /** Re-registers every purchase the store still holds for this device and
316
+ * resolves how many registered. Per-purchase failures go to onDiagnostic. */
317
+ async restore() {
318
+ const store = this.requireStore();
319
+ let restored = 0;
320
+ for (const tx of await store.restore()) {
321
+ try {
322
+ await this.registerStoreTransaction(store, tx, undefined, tx.store === "google");
323
+ restored += 1;
324
+ }
325
+ catch (err) {
326
+ this.diag("storeRestore", err);
327
+ }
328
+ }
329
+ return restored;
330
+ }
331
+ requireStore() {
332
+ const store = (0, store_js_1.getStoreAdapter)();
333
+ if (!store) {
334
+ throw new Error('Revnix: native purchases need `import "react-native-revnix/store"` before Revnix.configure(...) and a rebuilt app (pod install / Expo development build)');
335
+ }
336
+ return store;
337
+ }
338
+ async registerStoreTransaction(store, tx, opts, finish = true) {
339
+ const finishTx = () => finish
340
+ ? store.finish(tx.transactionId).catch((err) => this.diag("storeFinish", err))
341
+ : undefined;
342
+ try {
343
+ const result = await this.registerPurchase({
344
+ source: tx.store,
345
+ token: tx.token,
346
+ productId: tx.productId,
347
+ transactionId: tx.transactionId,
348
+ occurredAt: tx.occurredAt,
349
+ expiresAt: tx.expiresAt,
350
+ signedTransactionInfo: tx.signedTransactionInfo,
351
+ ...opts,
352
+ });
353
+ await finishTx();
354
+ return result;
355
+ }
356
+ catch (err) {
357
+ if (!(err instanceof errors_js_1.PurchaseBlockedError))
358
+ await finishTx();
359
+ throw err;
360
+ }
361
+ }
296
362
  postPurchase(customerId, input) {
297
363
  return this.request("POST", "/v1/purchases", {
298
364
  customerId,
@@ -1255,6 +1321,8 @@ class RevnixClient {
1255
1321
  // every return alongside its successor.
1256
1322
  this.lifecycleUnsubscribe?.();
1257
1323
  this.lifecycleUnsubscribe = null;
1324
+ this.stopStoreObserver?.();
1325
+ this.stopStoreObserver = null;
1258
1326
  }
1259
1327
  /** A block paywall reporting a paint string it could not read. Routed to the
1260
1328
  * same sink as every other swallowed failure, so a host that already wired
@@ -2,6 +2,7 @@ import { PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError,
2
2
  import { generateAnonymousId, generateEventId, generateViewId, resolveStorage, } from "./storage.js";
3
3
  import { DEVICE_HEADER, INSTALLED_AT_KEY, compactPayload, detectDeviceFacts, encodeDeviceHeader, } from "./device.js";
4
4
  import { SDK_VERSION } from "./version.js";
5
+ import { getStoreAdapter, } from "./store.js";
5
6
  import { browserLifecycle, DEFAULT_SESSION_TIMEOUT_MS, IMPLICIT_CONFIG_RETRY_MS, IMPLICIT_PLACEMENTS, PREVIEW_PLACEMENT_KEY, } from "./implicit.js";
6
7
  const CUSTOMER_ID_KEY = "revnix.customerId";
7
8
  const SESSION_STARTED_AT_KEY = "revnix.sessionStartedAt";
@@ -72,6 +73,7 @@ export class RevnixClient {
72
73
  this.implicitConfigPromise = null;
73
74
  this.implicitConfigRetryAt = 0;
74
75
  this.lifecycleUnsubscribe = null;
76
+ this.stopStoreObserver = null;
75
77
  /** The cold-start batch while it runs, resolving to whether it presented a
76
78
  * paywall. A deep link delivered on the first frame — SwiftUI's onOpenURL,
77
79
  * RN's getInitialURL — must wait for it, or the customer gets the launch
@@ -148,6 +150,10 @@ export class RevnixClient {
148
150
  // C7: retry any purchases that failed to register on a previous launch, so
149
151
  // a paying user never stays locked out because a POST didn't land.
150
152
  void this.retryPendingPurchases().catch((err) => this.diag("retryPendingPurchases", err));
153
+ const store = getStoreAdapter();
154
+ if (store) {
155
+ this.stopStoreObserver = store.observe((tx) => void this.registerStoreTransaction(store, tx).catch((err) => this.diag("storeObserver", err)));
156
+ }
151
157
  // REV-157: warm the placement cache at launch, so the first paywall of the
152
158
  // session can render offline. Fire-and-forget; a failed key just stays on
153
159
  // whatever an earlier launch cached.
@@ -290,6 +296,66 @@ export class RevnixClient {
290
296
  throw err;
291
297
  }
292
298
  }
299
+ /** Buys `productId` through the native store (StoreKit 2 / Play Billing),
300
+ * registers it with Revnix, then finishes the store transaction. Resolves
301
+ * null when the customer cancelled or the purchase is pending approval.
302
+ * Needs `import "react-native-revnix/store"` before configure(). A refused
303
+ * claim (PurchaseBlockedError) is never finished, so Google refunds it. */
304
+ async purchase(productId, opts) {
305
+ const store = this.requireStore();
306
+ const customerId = await this.customerIdPromise;
307
+ const tx = await store.purchase(productId, { customerId });
308
+ if (!tx)
309
+ return null;
310
+ return this.registerStoreTransaction(store, tx, opts);
311
+ }
312
+ /** Re-registers every purchase the store still holds for this device and
313
+ * resolves how many registered. Per-purchase failures go to onDiagnostic. */
314
+ async restore() {
315
+ const store = this.requireStore();
316
+ let restored = 0;
317
+ for (const tx of await store.restore()) {
318
+ try {
319
+ await this.registerStoreTransaction(store, tx, undefined, tx.store === "google");
320
+ restored += 1;
321
+ }
322
+ catch (err) {
323
+ this.diag("storeRestore", err);
324
+ }
325
+ }
326
+ return restored;
327
+ }
328
+ requireStore() {
329
+ const store = getStoreAdapter();
330
+ if (!store) {
331
+ throw new Error('Revnix: native purchases need `import "react-native-revnix/store"` before Revnix.configure(...) and a rebuilt app (pod install / Expo development build)');
332
+ }
333
+ return store;
334
+ }
335
+ async registerStoreTransaction(store, tx, opts, finish = true) {
336
+ const finishTx = () => finish
337
+ ? store.finish(tx.transactionId).catch((err) => this.diag("storeFinish", err))
338
+ : undefined;
339
+ try {
340
+ const result = await this.registerPurchase({
341
+ source: tx.store,
342
+ token: tx.token,
343
+ productId: tx.productId,
344
+ transactionId: tx.transactionId,
345
+ occurredAt: tx.occurredAt,
346
+ expiresAt: tx.expiresAt,
347
+ signedTransactionInfo: tx.signedTransactionInfo,
348
+ ...opts,
349
+ });
350
+ await finishTx();
351
+ return result;
352
+ }
353
+ catch (err) {
354
+ if (!(err instanceof PurchaseBlockedError))
355
+ await finishTx();
356
+ throw err;
357
+ }
358
+ }
293
359
  postPurchase(customerId, input) {
294
360
  return this.request("POST", "/v1/purchases", {
295
361
  customerId,
@@ -1252,6 +1318,8 @@ export class RevnixClient {
1252
1318
  // every return alongside its successor.
1253
1319
  this.lifecycleUnsubscribe?.();
1254
1320
  this.lifecycleUnsubscribe = null;
1321
+ this.stopStoreObserver?.();
1322
+ this.stopStoreObserver = null;
1255
1323
  }
1256
1324
  /** A block paywall reporting a paint string it could not read. Routed to the
1257
1325
  * same sink as every other swallowed failure, so a host that already wired
package/dist/esm/index.js CHANGED
@@ -3,6 +3,7 @@ import { setLocale } from "./blocks/locale.js";
3
3
  export { RevnixClient } from "./client.js";
4
4
  export { PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
5
5
  export { purchaseInputFromIap } from "./iap.js";
6
+ export { setStoreAdapter, } from "./store.js";
6
7
  export { memoryStorage, generateAnonymousId } from "./storage.js";
7
8
  export { SDK_VERSION } from "./version.js";
8
9
  export { DEVICE_HEADER, encodeDeviceHeader } from "./device.js";
@@ -0,0 +1,22 @@
1
+ import { NativeEventEmitter, NativeModules } from "react-native";
2
+ import { setStoreAdapter, } from "../store.js";
3
+ const native = NativeModules.RevnixStore;
4
+ const missing = () => Promise.reject(new Error("Revnix: RevnixStore native module not found. Run pod install / rebuild the app (Expo: use a development build)."));
5
+ export const RevnixStore = native
6
+ ? {
7
+ purchase: (productId, { customerId }) => native.purchase(productId, customerId),
8
+ restore: () => native.restore(),
9
+ finish: (transactionId) => native.finish(transactionId),
10
+ observe(onTransaction) {
11
+ const sub = new NativeEventEmitter(NativeModules.RevnixStore).addListener("revnixStoreTransaction", (tx) => onTransaction(tx));
12
+ return () => sub.remove();
13
+ },
14
+ }
15
+ : {
16
+ purchase: missing,
17
+ restore: missing,
18
+ finish: missing,
19
+ observe: () => () => { },
20
+ };
21
+ if (native)
22
+ setStoreAdapter(RevnixStore);
@@ -0,0 +1,9 @@
1
+ let adapter = null;
2
+ /** Installs the store that `RevnixClient.purchase()` / `restore()` drive.
3
+ * `import "react-native-revnix/store"` calls this with the native bridge. */
4
+ export function setStoreAdapter(next) {
5
+ adapter = next;
6
+ }
7
+ export function getStoreAdapter() {
8
+ return adapter;
9
+ }
@@ -17,6 +17,7 @@
17
17
  import * as React from "react";
18
18
  import { ActivityIndicator, Alert, Image, Linking, Pressable, ScrollView, StyleSheet, Text, View, } from "react-native";
19
19
  import { PREVIEW_PLACEMENT_KEY } from "../implicit.js";
20
+ import { getStoreAdapter } from "../store.js";
20
21
  import { withAlpha } from "../blocks/color.js";
21
22
  import { RevnixBlockPaywall, } from "./RevnixBlockPaywall.js";
22
23
  /** `config.blocks` is opaque on the core config (the non-UI entrypoint must
@@ -141,7 +142,7 @@ export function RevnixPaywall(props) {
141
142
  });
142
143
  onSelectPackage?.(packageId);
143
144
  }, [onSelectPackage, report, productOf]);
144
- const { onPurchase } = props;
145
+ const { onPurchase, onPurchaseCompleted } = props;
145
146
  // Rises per attempt so a retry after a failure is its own occurrence rather
146
147
  // than being absorbed as a duplicate of the first try.
147
148
  const attemptRef = React.useRef(0);
@@ -168,9 +169,26 @@ export function RevnixPaywall(props) {
168
169
  eventId: occurrence,
169
170
  });
170
171
  };
172
+ const purchase = onPurchase ??
173
+ (async () => {
174
+ if (!productId) {
175
+ return { status: "failed", message: "package has no productId" };
176
+ }
177
+ if (!client?.purchase) {
178
+ return { status: "failed", message: "pass client or onPurchase" };
179
+ }
180
+ const result = await client.purchase(productId, {
181
+ viewId: viewIdRef.current,
182
+ placementKey,
183
+ paywallId,
184
+ });
185
+ if (result)
186
+ onPurchaseCompleted?.(result);
187
+ return { status: result ? "completed" : "cancelled" };
188
+ });
171
189
  let returned;
172
190
  try {
173
- returned = onPurchase(packageId);
191
+ returned = purchase(packageId);
174
192
  }
175
193
  catch (err) {
176
194
  // A host that throws synchronously has failed the purchase as surely
@@ -183,14 +201,23 @@ export function RevnixPaywall(props) {
183
201
  return;
184
202
  }
185
203
  settle(returned);
186
- }, [onPurchase, report, productOf, placementKey]);
204
+ }, [onPurchase, onPurchaseCompleted, report, productOf, placementKey, paywallId, client]);
187
205
  const { onRestore } = props;
188
- const handleRestore = React.useMemo(() => onRestore
189
- ? () => {
190
- report("restore");
191
- onRestore();
206
+ const handleRestore = React.useMemo(() => {
207
+ if (onRestore) {
208
+ return () => {
209
+ report("restore");
210
+ onRestore();
211
+ };
192
212
  }
193
- : undefined, [onRestore, report]);
213
+ const restore = client?.restore?.bind(client);
214
+ if (!restore || !getStoreAdapter())
215
+ return undefined;
216
+ return () => {
217
+ report("restore");
218
+ void restore().catch((err) => client?.reportRenderDiagnostic?.(String(err?.message ?? err)));
219
+ };
220
+ }, [onRestore, report, client]);
194
221
  // The host's dismissal runs whatever happens to the beacon — an analytics
195
222
  // failure must never trap the customer on the screen (REV-252).
196
223
  const { onClose } = props;
@@ -1,4 +1,4 @@
1
1
  // L3: the single source of the SDK version inside src/. Must match
2
2
  // package.json "version" — version.test.ts fails the build when they drift
3
3
  // (they had: client.ts said 0.7.0 while the package shipped 0.9.0).
4
- export const SDK_VERSION = "1.4.2";
4
+ export const SDK_VERSION = "1.5.0";
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ import type { RevnixConfig } from "./types.js";
4
4
  export { RevnixClient } from "./client.js";
5
5
  export { PurchaseBlockedError, RevnixAuthError, RevnixError, RevnixNetworkError, RevnixRateLimitError, RevnixTimeoutError, } from "./errors.js";
6
6
  export { purchaseInputFromIap, type IapPurchase } from "./iap.js";
7
+ export { setStoreAdapter, type StoreAdapter, type StoreTransaction, } from "./store.js";
7
8
  export { memoryStorage, generateAnonymousId } from "./storage.js";
8
9
  export { SDK_VERSION } from "./version.js";
9
10
  export { DEVICE_HEADER, encodeDeviceHeader } from "./device.js";