react-native-revnix 1.4.0 → 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.
- package/README.md +139 -714
- package/android/build.gradle +34 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/kotlin/com/revnix/reactnative/RevnixStoreModule.kt +247 -0
- package/android/src/main/kotlin/com/revnix/reactnative/RevnixStorePackage.kt +14 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.js +69 -1
- package/dist/esm/client.js +69 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/store/index.js +22 -0
- package/dist/esm/store.js +9 -0
- package/dist/esm/ui/RevnixPaywall.js +36 -9
- package/dist/esm/version.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/store/index.d.ts +2 -0
- package/dist/store/index.js +25 -0
- package/dist/store.d.ts +30 -0
- package/dist/store.js +13 -0
- package/dist/types.d.ts +1 -1
- package/dist/ui/RevnixPaywall.d.ts +13 -3
- package/dist/ui/RevnixPaywall.js +36 -9
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/ios/RevnixStore.m +18 -0
- package/ios/RevnixStore.swift +106 -0
- package/package.json +20 -2
- package/react-native-revnix.podspec +22 -0
- package/src/client.ts +88 -1
- package/src/index.ts +5 -0
- package/src/store/index.ts +44 -0
- package/src/store.ts +41 -0
- package/src/types.ts +1 -1
- package/src/ui/RevnixPaywall.tsx +55 -18
- package/src/version.ts +1 -1
- package/store.d.ts +1 -0
- package/store.js +1 -0
- package/ui.js +1 -1
|
@@ -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
|
|
@@ -1592,7 +1660,7 @@ class RevnixClient {
|
|
|
1592
1660
|
Authorization: `Bearer ${this.apiKey}`,
|
|
1593
1661
|
// L3: identify the SDK on EVERY request (reads included) so the
|
|
1594
1662
|
// backend can triage by version without depending on body fields.
|
|
1595
|
-
"X-Revnix-SDK": `revnix
|
|
1663
|
+
"X-Revnix-SDK": `react-native-revnix/${version_js_1.SDK_VERSION}`,
|
|
1596
1664
|
...(reportedBgFailures > 0
|
|
1597
1665
|
? { "X-Revnix-Bg-Failures": String(reportedBgFailures) }
|
|
1598
1666
|
: {}),
|
package/dist/esm/client.js
CHANGED
|
@@ -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
|
|
@@ -1589,7 +1657,7 @@ export class RevnixClient {
|
|
|
1589
1657
|
Authorization: `Bearer ${this.apiKey}`,
|
|
1590
1658
|
// L3: identify the SDK on EVERY request (reads included) so the
|
|
1591
1659
|
// backend can triage by version without depending on body fields.
|
|
1592
|
-
"X-Revnix-SDK": `revnix
|
|
1660
|
+
"X-Revnix-SDK": `react-native-revnix/${SDK_VERSION}`,
|
|
1593
1661
|
...(reportedBgFailures > 0
|
|
1594
1662
|
? { "X-Revnix-Bg-Failures": String(reportedBgFailures) }
|
|
1595
1663
|
: {}),
|
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
|
+
}
|
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
// An unrecognized template (config published by a newer dashboard) falls
|
|
13
13
|
// back to the classic structure instead of rendering nothing.
|
|
14
14
|
//
|
|
15
|
-
// Import from "revnix
|
|
15
|
+
// Import from "react-native-revnix/ui" — the core entrypoint stays free of
|
|
16
16
|
// react / react-native so non-RN consumers are unaffected.
|
|
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 =
|
|
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(() =>
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
206
|
+
const handleRestore = React.useMemo(() => {
|
|
207
|
+
if (onRestore) {
|
|
208
|
+
return () => {
|
|
209
|
+
report("restore");
|
|
210
|
+
onRestore();
|
|
211
|
+
};
|
|
192
212
|
}
|
|
193
|
-
|
|
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;
|
package/dist/esm/version.js
CHANGED
|
@@ -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
|
+
export const SDK_VERSION = "1.5.0";
|