react-native-revnix 1.4.2 → 1.6.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
@@ -1,4 +1,5 @@
1
1
  import type { AdRevenue, AliasResult, CustomerEntitlements, IdentifyResult, IdentifyStrategy, IdentityInfo, LastDeepLink, MmpAttribution, PaywallInteraction, PlacementResolution, RegisterPurchaseInput, RegisterPurchaseResult, RevnixAttribution, RevnixConfig } from "./types.js";
2
+ export declare function paywallImageUrls(value: unknown): string[];
2
3
  export declare class RevnixClient {
3
4
  private readonly apiKey;
4
5
  private readonly baseUrl;
@@ -33,6 +34,7 @@ export declare class RevnixClient {
33
34
  private implicitConfigPromise;
34
35
  private implicitConfigRetryAt;
35
36
  private lifecycleUnsubscribe;
37
+ private stopStoreObserver;
36
38
  /** The cold-start batch while it runs, resolving to whether it presented a
37
39
  * paywall. A deep link delivered on the first frame — SwiftUI's onOpenURL,
38
40
  * RN's getInitialURL — must wait for it, or the customer gets the launch
@@ -55,6 +57,9 @@ export declare class RevnixClient {
55
57
  * /v1/installs uses, and independent of the device header (which an app
56
58
  * can turn off entirely). */
57
59
  private freshInstall;
60
+ private eventFlush;
61
+ private eventTimer;
62
+ private eventRetryAt;
58
63
  constructor(config: RevnixConfig);
59
64
  /** The id every call is made as: the stored identified/canonical id, or a
60
65
  * generated-and-persisted anonymous id (rvx_anon_…) on first launch. */
@@ -90,10 +95,22 @@ export declare class RevnixClient {
90
95
  * PurchaseBlockedError when the token belongs to another customer and the
91
96
  * app's transfer behavior forbids reassignment. */
92
97
  registerPurchase(input: RegisterPurchaseInput): Promise<RegisterPurchaseResult>;
98
+ /** Buys `productId` through the native store (StoreKit 2 / Play Billing),
99
+ * registers it with Revnix, then finishes the store transaction. Resolves
100
+ * null when the customer cancelled or the purchase is pending approval.
101
+ * Needs `import "react-native-revnix/store"` before configure(). A refused
102
+ * claim (PurchaseBlockedError) is never finished, so Google refunds it. */
103
+ purchase(productId: string, opts?: Pick<RegisterPurchaseInput, "viewId" | "paywallId" | "placementKey">): Promise<RegisterPurchaseResult | null>;
104
+ /** Re-registers every purchase the store still holds for this device and
105
+ * resolves how many registered. Per-purchase failures go to onDiagnostic. */
106
+ restore(): Promise<number>;
107
+ private requireStore;
108
+ private registerStoreTransaction;
93
109
  private postPurchase;
94
110
  /** C7: retry purchases that failed to register and were persisted locally.
95
111
  * Runs automatically at construction; call again when connectivity returns.
96
- * Idempotent and safe to call repeatedly. */
112
+ * Idempotent and safe to call repeatedly. Purchases queued more than 30
113
+ * days ago are dropped with a diagnostic. */
97
114
  retryPendingPurchases(): Promise<void>;
98
115
  private freshEntitlements;
99
116
  private inflightEntitlements;
@@ -138,12 +155,22 @@ export declare class RevnixClient {
138
155
  * RevnixAuthError locally, before any network call, like identify(). */
139
156
  getIdentity(): Promise<IdentityInfo>;
140
157
  /** Resolve a named in-app location to the currently published offering —
141
- * the one-line paywall call. REV-157: reads the network first and caches
142
- * the result; if that read FAILS (offline / incident) it falls back to the
143
- * last cached resolution with `stale: true`, so the paywall renders the
144
- * last published version rather than not at all. Throws only when there
145
- * is nothing cached. */
158
+ * the one-line paywall call. Served instantly from a cache under 24 h old
159
+ * that was fetched for this customer since the last `setAttributes` or
160
+ * purchase, refreshed in the background. Otherwise reads the network and
161
+ * caches the result; if that read FAILS (offline / incident) it falls back
162
+ * to the last cached resolution with `stale: true`, so the paywall renders
163
+ * the last published version rather than not at all. Throws only when
164
+ * there is nothing cached. A deliberate rejection seen by the background
165
+ * refresh drops the cache, so the next resolve throws it. */
146
166
  resolvePlacement(placementKey: string): Promise<PlacementResolution>;
167
+ /** Fetch these placements now so the next `resolvePlacement` answers from
168
+ * the cache. Never throws; failures report to `onDiagnostic`. Paywall
169
+ * images are warmed by `prefetchPaywallImages` from
170
+ * `react-native-revnix/ui`. */
171
+ preload(placementKeys: string[]): Promise<void>;
172
+ private refreshPlacement;
173
+ private fetchPlacement;
147
174
  /** REV-157: the last resolution cached for `placementKey`, with NO network
148
175
  * call — for an instant, offline-safe paywall at launch. Always
149
176
  * `stale: true`; follow with resolvePlacement() to revalidate. Null if
@@ -195,7 +222,8 @@ export declare class RevnixClient {
195
222
  * - `restore` — restore purchases was tapped
196
223
  * - `error` — the paywall itself failed (config, products, render)
197
224
  *
198
- * Fire-and-forget like the other beacons: never blocks, never throws.
225
+ * Queued and sent in batches (see `flushEvents`): resolves once queued,
226
+ * never throws.
199
227
  *
200
228
  * `viewId` is the id `logPaywallShown` returned for THIS display. Passing
201
229
  * it is what threads the whole life of one impression together and puts
@@ -221,15 +249,28 @@ export declare class RevnixClient {
221
249
  message?: string;
222
250
  /** Idempotency key for this occurrence; defaults to `viewId`. */
223
251
  eventId?: string;
224
- }): void;
252
+ }): Promise<void>;
225
253
  /** Reports one ad impression's payout (POST /v1/ad-revenue).
226
254
  * Fire-and-forget like the other beacons: never blocks, never throws. */
227
255
  logAdRevenue(input: AdRevenue): void;
228
256
  /** Reports one custom in-app event (POST /v1/events), not for purchases.
229
- * Fire-and-forget like the other beacons: never blocks, never throws. */
257
+ * Queued and sent in batches (see `flushEvents`): resolves once queued,
258
+ * never throws. */
230
259
  track(event: string, properties?: Record<string, string | number | boolean>, options?: {
231
260
  eventId?: string;
232
- }): void;
261
+ }): Promise<void>;
262
+ /** Send every queued `track` and paywall interaction event now. Events are
263
+ * otherwise sent in batches: at 20 queued, 10 s after the first, when the
264
+ * app goes to the background, and on the next launch. Never throws; a
265
+ * transient failure keeps the events and holds further sends for 30 s. */
266
+ flushEvents(): Promise<void>;
267
+ private readEvents;
268
+ private mutateEvents;
269
+ private enqueueEvent;
270
+ private armEventTimer;
271
+ private startEventFlush;
272
+ private runEventFlush;
273
+ private removeEvents;
233
274
  /** PT11: forward an MMP's attribution callback (Adjust, AppsFlyer, …) so
234
275
  * Revnix credits revenue to the right network/campaign. */
235
276
  setAttribution(attribution: MmpAttribution): void;
@@ -349,6 +390,7 @@ export declare class RevnixClient {
349
390
  * cold start to ONE paywall. Never throws past its caller's catch. */
350
391
  private triggerImplicit;
351
392
  private consumePreviousSessionMs;
393
+ private subscribeLifecycle;
352
394
  /** REV-272: the launch-time moments, plus the foreground listener that
353
395
  * `session_start` is built on.
354
396
  *
@@ -393,7 +435,12 @@ export declare class RevnixClient {
393
435
  private readPlacementMap;
394
436
  private readPlacementCache;
395
437
  private writePlacementCache;
396
- private queueLock;
438
+ private deletePlacementCache;
439
+ private mutatePlacements;
440
+ private placementsInvalidatedAt;
441
+ private invalidatePlacements;
442
+ private storageLock;
443
+ private withStorageLock;
397
444
  private mutateQueue;
398
445
  private readPurchaseQueue;
399
446
  private writePurchaseQueue;