rampkit-expo-dev 0.0.23 → 0.0.25
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/android/build.gradle +3 -0
- package/android/src/main/java/expo/modules/rampkit/RampKitModule.kt +317 -9
- package/build/RampKit.d.ts +1 -36
- package/build/RampKit.js +10 -37
- package/build/RampKitNative.d.ts +14 -0
- package/build/RampKitNative.js +34 -1
- package/build/RampkitOverlay.js +26 -6
- package/build/index.d.ts +1 -1
- package/build/index.js +2 -1
- package/ios/RampKitModule.swift +217 -2
- package/package.json +1 -1
package/android/build.gradle
CHANGED
|
@@ -74,6 +74,9 @@ dependencies {
|
|
|
74
74
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
|
|
75
75
|
implementation 'com.google.android.play:review:2.0.1'
|
|
76
76
|
implementation 'com.google.android.play:review-ktx:2.0.1'
|
|
77
|
+
implementation 'com.android.billingclient:billing:6.1.0'
|
|
78
|
+
implementation 'com.android.billingclient:billing-ktx:6.1.0'
|
|
79
|
+
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
def getKotlinVersion() {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
package expo.modules.rampkit
|
|
2
2
|
|
|
3
3
|
import android.Manifest
|
|
4
|
-
import android.app.Activity
|
|
5
4
|
import android.app.ActivityManager
|
|
6
5
|
import android.app.NotificationChannel
|
|
7
6
|
import android.app.NotificationManager
|
|
@@ -16,26 +15,39 @@ import android.os.Vibrator
|
|
|
16
15
|
import android.os.VibratorManager
|
|
17
16
|
import android.provider.Settings
|
|
18
17
|
import android.util.DisplayMetrics
|
|
18
|
+
import android.util.Log
|
|
19
19
|
import android.view.WindowManager
|
|
20
20
|
import androidx.core.app.ActivityCompat
|
|
21
21
|
import androidx.core.content.ContextCompat
|
|
22
|
+
import com.android.billingclient.api.*
|
|
22
23
|
import com.google.android.play.core.review.ReviewManagerFactory
|
|
23
24
|
import expo.modules.kotlin.Promise
|
|
24
25
|
import expo.modules.kotlin.modules.Module
|
|
25
26
|
import expo.modules.kotlin.modules.ModuleDefinition
|
|
27
|
+
import kotlinx.coroutines.*
|
|
28
|
+
import org.json.JSONObject
|
|
29
|
+
import java.io.OutputStreamWriter
|
|
30
|
+
import java.net.HttpURLConnection
|
|
31
|
+
import java.net.URL
|
|
26
32
|
import java.text.SimpleDateFormat
|
|
27
33
|
import java.util.Date
|
|
28
34
|
import java.util.Locale
|
|
29
35
|
import java.util.TimeZone
|
|
30
36
|
import java.util.UUID
|
|
31
37
|
|
|
32
|
-
class RampKitModule : Module() {
|
|
38
|
+
class RampKitModule : Module(), PurchasesUpdatedListener {
|
|
39
|
+
private val TAG = "RampKit"
|
|
33
40
|
private val PREFS_NAME = "rampkit_prefs"
|
|
34
41
|
private val USER_ID_KEY = "rk_user_id"
|
|
35
42
|
private val INSTALL_DATE_KEY = "rk_install_date"
|
|
36
43
|
private val LAUNCH_COUNT_KEY = "rk_launch_count"
|
|
37
44
|
private val LAST_LAUNCH_KEY = "rk_last_launch"
|
|
38
45
|
|
|
46
|
+
private var billingClient: BillingClient? = null
|
|
47
|
+
private var appId: String? = null
|
|
48
|
+
private var userId: String? = null
|
|
49
|
+
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
50
|
+
|
|
39
51
|
private val context: Context
|
|
40
52
|
get() = requireNotNull(appContext.reactContext)
|
|
41
53
|
|
|
@@ -94,7 +106,7 @@ class RampKitModule : Module() {
|
|
|
94
106
|
}
|
|
95
107
|
|
|
96
108
|
AsyncFunction("isReviewAvailable") {
|
|
97
|
-
true
|
|
109
|
+
true
|
|
98
110
|
}
|
|
99
111
|
|
|
100
112
|
AsyncFunction("getStoreUrl") {
|
|
@@ -112,6 +124,300 @@ class RampKitModule : Module() {
|
|
|
112
124
|
AsyncFunction("getNotificationPermissions") {
|
|
113
125
|
getNotificationPermissions()
|
|
114
126
|
}
|
|
127
|
+
|
|
128
|
+
// ============================================================================
|
|
129
|
+
// Transaction Observer (Google Play Billing)
|
|
130
|
+
// ============================================================================
|
|
131
|
+
|
|
132
|
+
AsyncFunction("startTransactionObserver") { appId: String ->
|
|
133
|
+
startTransactionObserver(appId)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
AsyncFunction("stopTransactionObserver") {
|
|
137
|
+
stopTransactionObserver()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
OnDestroy {
|
|
141
|
+
stopTransactionObserver()
|
|
142
|
+
coroutineScope.cancel()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ============================================================================
|
|
147
|
+
// PurchasesUpdatedListener Implementation
|
|
148
|
+
// ============================================================================
|
|
149
|
+
|
|
150
|
+
override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList<Purchase>?) {
|
|
151
|
+
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
|
|
152
|
+
for (purchase in purchases) {
|
|
153
|
+
handlePurchase(purchase)
|
|
154
|
+
}
|
|
155
|
+
} else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) {
|
|
156
|
+
Log.d(TAG, "User cancelled the purchase")
|
|
157
|
+
} else {
|
|
158
|
+
Log.e(TAG, "Purchase failed: ${billingResult.debugMessage}")
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// Transaction Observer
|
|
164
|
+
// ============================================================================
|
|
165
|
+
|
|
166
|
+
private fun startTransactionObserver(appId: String) {
|
|
167
|
+
this.appId = appId
|
|
168
|
+
this.userId = getOrCreateUserId()
|
|
169
|
+
|
|
170
|
+
billingClient = BillingClient.newBuilder(context)
|
|
171
|
+
.setListener(this)
|
|
172
|
+
.enablePendingPurchases()
|
|
173
|
+
.build()
|
|
174
|
+
|
|
175
|
+
billingClient?.startConnection(object : BillingClientStateListener {
|
|
176
|
+
override fun onBillingSetupFinished(billingResult: BillingResult) {
|
|
177
|
+
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
|
178
|
+
Log.d(TAG, "Billing client connected")
|
|
179
|
+
// Query existing purchases
|
|
180
|
+
queryExistingPurchases()
|
|
181
|
+
} else {
|
|
182
|
+
Log.e(TAG, "Billing setup failed: ${billingResult.debugMessage}")
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
override fun onBillingServiceDisconnected() {
|
|
187
|
+
Log.d(TAG, "Billing service disconnected")
|
|
188
|
+
// Try to reconnect
|
|
189
|
+
coroutineScope.launch {
|
|
190
|
+
delay(5000)
|
|
191
|
+
startTransactionObserver(appId)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
Log.d(TAG, "Transaction observer started")
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private fun stopTransactionObserver() {
|
|
200
|
+
billingClient?.endConnection()
|
|
201
|
+
billingClient = null
|
|
202
|
+
Log.d(TAG, "Transaction observer stopped")
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private fun queryExistingPurchases() {
|
|
206
|
+
val client = billingClient ?: return
|
|
207
|
+
|
|
208
|
+
// Query in-app purchases
|
|
209
|
+
client.queryPurchasesAsync(
|
|
210
|
+
QueryPurchasesParams.newBuilder()
|
|
211
|
+
.setProductType(BillingClient.ProductType.INAPP)
|
|
212
|
+
.build()
|
|
213
|
+
) { billingResult, purchases ->
|
|
214
|
+
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
|
215
|
+
for (purchase in purchases) {
|
|
216
|
+
if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED && !purchase.isAcknowledged) {
|
|
217
|
+
handlePurchase(purchase)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Query subscriptions
|
|
224
|
+
client.queryPurchasesAsync(
|
|
225
|
+
QueryPurchasesParams.newBuilder()
|
|
226
|
+
.setProductType(BillingClient.ProductType.SUBS)
|
|
227
|
+
.build()
|
|
228
|
+
) { billingResult, purchases ->
|
|
229
|
+
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
|
230
|
+
for (purchase in purchases) {
|
|
231
|
+
if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED && !purchase.isAcknowledged) {
|
|
232
|
+
handlePurchase(purchase)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private fun handlePurchase(purchase: Purchase) {
|
|
240
|
+
val appId = this.appId ?: return
|
|
241
|
+
val userId = this.userId ?: return
|
|
242
|
+
|
|
243
|
+
when (purchase.purchaseState) {
|
|
244
|
+
Purchase.PurchaseState.PURCHASED -> {
|
|
245
|
+
// Acknowledge the purchase if not already acknowledged
|
|
246
|
+
if (!purchase.isAcknowledged) {
|
|
247
|
+
val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
|
|
248
|
+
.setPurchaseToken(purchase.purchaseToken)
|
|
249
|
+
.build()
|
|
250
|
+
|
|
251
|
+
billingClient?.acknowledgePurchase(acknowledgePurchaseParams) { result ->
|
|
252
|
+
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
|
253
|
+
Log.d(TAG, "Purchase acknowledged")
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Get product details for price info
|
|
259
|
+
coroutineScope.launch {
|
|
260
|
+
val productDetails = getProductDetails(purchase.products.firstOrNull() ?: "")
|
|
261
|
+
|
|
262
|
+
val properties = mutableMapOf<String, Any?>(
|
|
263
|
+
"productId" to purchase.products.firstOrNull(),
|
|
264
|
+
"transactionId" to purchase.orderId,
|
|
265
|
+
"originalTransactionId" to purchase.orderId,
|
|
266
|
+
"purchaseToken" to purchase.purchaseToken,
|
|
267
|
+
"purchaseDate" to getIso8601Timestamp(purchase.purchaseTime),
|
|
268
|
+
"quantity" to purchase.quantity,
|
|
269
|
+
"isAutoRenewing" to purchase.isAutoRenewing
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
productDetails?.let { details ->
|
|
273
|
+
val pricingPhase = details.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()
|
|
274
|
+
?: details.oneTimePurchaseOfferDetails?.let { oneTime ->
|
|
275
|
+
object {
|
|
276
|
+
val priceAmountMicros = oneTime.priceAmountMicros
|
|
277
|
+
val priceCurrencyCode = oneTime.priceCurrencyCode
|
|
278
|
+
val formattedPrice = oneTime.formattedPrice
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (pricingPhase != null) {
|
|
283
|
+
properties["price"] = when (pricingPhase) {
|
|
284
|
+
is ProductDetails.PricingPhase -> pricingPhase.priceAmountMicros / 1_000_000.0
|
|
285
|
+
else -> (pricingPhase as? Any)?.let {
|
|
286
|
+
(it.javaClass.getMethod("getPriceAmountMicros").invoke(it) as Long) / 1_000_000.0
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
properties["currency"] = when (pricingPhase) {
|
|
290
|
+
is ProductDetails.PricingPhase -> pricingPhase.priceCurrencyCode
|
|
291
|
+
else -> (pricingPhase as? Any)?.let {
|
|
292
|
+
it.javaClass.getMethod("getPriceCurrencyCode").invoke(it) as String
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
properties["localizedPrice"] = when (pricingPhase) {
|
|
296
|
+
is ProductDetails.PricingPhase -> pricingPhase.formattedPrice
|
|
297
|
+
else -> (pricingPhase as? Any)?.let {
|
|
298
|
+
it.javaClass.getMethod("getFormattedPrice").invoke(it) as String
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
properties["localizedName"] = details.name
|
|
303
|
+
properties["productType"] = details.productType
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
sendPurchaseEvent(appId, userId, "purchase_completed", properties)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
Purchase.PurchaseState.PENDING -> {
|
|
310
|
+
Log.d(TAG, "Purchase pending: ${purchase.products}")
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private suspend fun getProductDetails(productId: String): ProductDetails? {
|
|
316
|
+
if (productId.isEmpty()) return null
|
|
317
|
+
val client = billingClient ?: return null
|
|
318
|
+
|
|
319
|
+
// Try as subscription first
|
|
320
|
+
var result = queryProductDetails(client, productId, BillingClient.ProductType.SUBS)
|
|
321
|
+
if (result == null) {
|
|
322
|
+
// Try as in-app purchase
|
|
323
|
+
result = queryProductDetails(client, productId, BillingClient.ProductType.INAPP)
|
|
324
|
+
}
|
|
325
|
+
return result
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private suspend fun queryProductDetails(
|
|
329
|
+
client: BillingClient,
|
|
330
|
+
productId: String,
|
|
331
|
+
productType: String
|
|
332
|
+
): ProductDetails? = suspendCancellableCoroutine { continuation ->
|
|
333
|
+
val productList = listOf(
|
|
334
|
+
QueryProductDetailsParams.Product.newBuilder()
|
|
335
|
+
.setProductId(productId)
|
|
336
|
+
.setProductType(productType)
|
|
337
|
+
.build()
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
val params = QueryProductDetailsParams.newBuilder()
|
|
341
|
+
.setProductList(productList)
|
|
342
|
+
.build()
|
|
343
|
+
|
|
344
|
+
client.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
|
|
345
|
+
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
|
346
|
+
continuation.resume(productDetailsList.firstOrNull()) {}
|
|
347
|
+
} else {
|
|
348
|
+
continuation.resume(null) {}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private fun sendPurchaseEvent(appId: String, userId: String, eventName: String, properties: Map<String, Any?>) {
|
|
354
|
+
coroutineScope.launch {
|
|
355
|
+
try {
|
|
356
|
+
val event = JSONObject().apply {
|
|
357
|
+
put("appId", appId)
|
|
358
|
+
put("appUserId", userId)
|
|
359
|
+
put("eventId", UUID.randomUUID().toString().lowercase())
|
|
360
|
+
put("eventName", eventName)
|
|
361
|
+
put("sessionId", UUID.randomUUID().toString().lowercase())
|
|
362
|
+
put("occurredAt", getIso8601Timestamp())
|
|
363
|
+
put("device", JSONObject().apply {
|
|
364
|
+
put("platform", "Android")
|
|
365
|
+
put("platformVersion", Build.VERSION.RELEASE)
|
|
366
|
+
put("deviceModel", "${Build.MANUFACTURER} ${Build.MODEL}")
|
|
367
|
+
put("sdkVersion", "1.0.0")
|
|
368
|
+
put("appVersion", getAppVersion())
|
|
369
|
+
put("buildNumber", getBuildNumber())
|
|
370
|
+
})
|
|
371
|
+
put("context", JSONObject().apply {
|
|
372
|
+
put("locale", Locale.getDefault().toLanguageTag())
|
|
373
|
+
put("regionCode", Locale.getDefault().country)
|
|
374
|
+
})
|
|
375
|
+
put("properties", JSONObject(properties.filterValues { it != null }))
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
val url = URL("https://uustlzuvjmochxkxatfx.supabase.co/functions/v1/app-user-events")
|
|
379
|
+
val connection = url.openConnection() as HttpURLConnection
|
|
380
|
+
connection.apply {
|
|
381
|
+
requestMethod = "POST"
|
|
382
|
+
setRequestProperty("Content-Type", "application/json")
|
|
383
|
+
setRequestProperty("apikey", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV1c3RsenV2am1vY2h4a3hhdGZ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzU1NjQ0NjYsImV4cCI6MjA1MTE0MDQ2Nn0.5cNrph5LHmssNo39UKpULkC9n4OD5n6gsnTEQV-gwQk")
|
|
384
|
+
setRequestProperty("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV1c3RsenV2am1vY2h4a3hhdGZ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzU1NjQ0NjYsImV4cCI6MjA1MTE0MDQ2Nn0.5cNrph5LHmssNo39UKpULkC9n4OD5n6gsnTEQV-gwQk")
|
|
385
|
+
doOutput = true
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
OutputStreamWriter(connection.outputStream).use { writer ->
|
|
389
|
+
writer.write(event.toString())
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
val responseCode = connection.responseCode
|
|
393
|
+
Log.d(TAG, "Purchase event sent: $eventName - Status: $responseCode")
|
|
394
|
+
connection.disconnect()
|
|
395
|
+
} catch (e: Exception) {
|
|
396
|
+
Log.e(TAG, "Failed to send purchase event", e)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private fun getAppVersion(): String? {
|
|
402
|
+
return try {
|
|
403
|
+
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
|
404
|
+
} catch (e: Exception) {
|
|
405
|
+
null
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private fun getBuildNumber(): String? {
|
|
410
|
+
return try {
|
|
411
|
+
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
|
412
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
413
|
+
packageInfo.longVersionCode.toString()
|
|
414
|
+
} else {
|
|
415
|
+
@Suppress("DEPRECATION")
|
|
416
|
+
packageInfo.versionCode.toString()
|
|
417
|
+
}
|
|
418
|
+
} catch (e: Exception) {
|
|
419
|
+
null
|
|
420
|
+
}
|
|
115
421
|
}
|
|
116
422
|
|
|
117
423
|
// Device Info Collection
|
|
@@ -138,7 +444,7 @@ class RampKitModule : Module() {
|
|
|
138
444
|
"bundleId" to context.packageName,
|
|
139
445
|
"appName" to getAppName(),
|
|
140
446
|
"appVersion" to packageInfo?.versionName,
|
|
141
|
-
"buildNumber" to
|
|
447
|
+
"buildNumber" to getBuildNumberFromPackage(packageInfo),
|
|
142
448
|
"platform" to "Android",
|
|
143
449
|
"platformVersion" to Build.VERSION.RELEASE,
|
|
144
450
|
"deviceModel" to "${Build.MANUFACTURER} ${Build.MODEL}",
|
|
@@ -295,7 +601,6 @@ class RampKitModule : Module() {
|
|
|
295
601
|
|
|
296
602
|
// Notifications
|
|
297
603
|
private fun requestNotificationPermissions(options: Map<String, Any>?): Map<String, Any> {
|
|
298
|
-
// Create notification channel for Android 8+
|
|
299
604
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
300
605
|
val androidOptions = options?.get("android") as? Map<*, *>
|
|
301
606
|
val channelId = androidOptions?.get("channelId") as? String ?: "default"
|
|
@@ -314,7 +619,6 @@ class RampKitModule : Module() {
|
|
|
314
619
|
notificationManager.createNotificationChannel(channel)
|
|
315
620
|
}
|
|
316
621
|
|
|
317
|
-
// For Android 13+, check POST_NOTIFICATIONS permission
|
|
318
622
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
319
623
|
val granted = ContextCompat.checkSelfPermission(
|
|
320
624
|
context,
|
|
@@ -322,7 +626,6 @@ class RampKitModule : Module() {
|
|
|
322
626
|
) == PackageManager.PERMISSION_GRANTED
|
|
323
627
|
|
|
324
628
|
if (!granted) {
|
|
325
|
-
// Request permission - this will be handled by the app
|
|
326
629
|
appContext.currentActivity?.let { activity ->
|
|
327
630
|
ActivityCompat.requestPermissions(
|
|
328
631
|
activity,
|
|
@@ -339,7 +642,6 @@ class RampKitModule : Module() {
|
|
|
339
642
|
)
|
|
340
643
|
}
|
|
341
644
|
|
|
342
|
-
// For older Android versions, notifications are allowed by default
|
|
343
645
|
return mapOf(
|
|
344
646
|
"granted" to true,
|
|
345
647
|
"status" to "granted",
|
|
@@ -388,7 +690,7 @@ class RampKitModule : Module() {
|
|
|
388
690
|
}
|
|
389
691
|
}
|
|
390
692
|
|
|
391
|
-
private fun
|
|
693
|
+
private fun getBuildNumberFromPackage(packageInfo: android.content.pm.PackageInfo?): String? {
|
|
392
694
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
393
695
|
packageInfo?.longVersionCode?.toString()
|
|
394
696
|
} else {
|
|
@@ -442,4 +744,10 @@ class RampKitModule : Module() {
|
|
|
442
744
|
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
|
443
745
|
return sdf.format(Date())
|
|
444
746
|
}
|
|
747
|
+
|
|
748
|
+
private fun getIso8601Timestamp(millis: Long): String {
|
|
749
|
+
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
|
750
|
+
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
|
751
|
+
return sdf.format(Date(millis))
|
|
752
|
+
}
|
|
445
753
|
}
|
package/build/RampKit.d.ts
CHANGED
|
@@ -67,43 +67,8 @@ export declare class RampKitCore {
|
|
|
67
67
|
* Track a CTA tap
|
|
68
68
|
*/
|
|
69
69
|
trackCtaTap(buttonId: string, buttonText?: string): void;
|
|
70
|
-
/**
|
|
71
|
-
* Track paywall shown
|
|
72
|
-
*/
|
|
73
|
-
trackPaywallShown(paywallId: string, placement?: string, products?: Array<{
|
|
74
|
-
productId: string;
|
|
75
|
-
price?: number;
|
|
76
|
-
currency?: string;
|
|
77
|
-
}>): void;
|
|
78
|
-
/**
|
|
79
|
-
* Track paywall primary action tap
|
|
80
|
-
*/
|
|
81
|
-
trackPaywallPrimaryActionTap(paywallId: string, productId?: string): void;
|
|
82
|
-
/**
|
|
83
|
-
* Track paywall closed
|
|
84
|
-
*/
|
|
85
|
-
trackPaywallClosed(paywallId: string, reason: "dismissed" | "purchased" | "backgrounded"): void;
|
|
86
|
-
/**
|
|
87
|
-
* Track purchase started
|
|
88
|
-
*/
|
|
89
|
-
trackPurchaseStarted(productId: string, amount?: number, currency?: string): void;
|
|
90
|
-
/**
|
|
91
|
-
* Track purchase completed
|
|
92
|
-
*/
|
|
93
|
-
trackPurchaseCompleted(properties: {
|
|
94
|
-
productId: string;
|
|
95
|
-
amount: number;
|
|
96
|
-
currency: string;
|
|
97
|
-
transactionId: string;
|
|
98
|
-
originalTransactionId?: string;
|
|
99
|
-
purchaseDate?: string;
|
|
100
|
-
}): void;
|
|
101
|
-
/**
|
|
102
|
-
* Track purchase failed
|
|
103
|
-
*/
|
|
104
|
-
trackPurchaseFailed(productId: string, errorCode: string, errorMessage: string): void;
|
|
105
70
|
/**
|
|
106
71
|
* Cleanup SDK resources
|
|
107
72
|
*/
|
|
108
|
-
cleanup(): void
|
|
73
|
+
cleanup(): Promise<void>;
|
|
109
74
|
}
|
package/build/RampKit.js
CHANGED
|
@@ -10,6 +10,7 @@ const RampkitOverlay_1 = require("./RampkitOverlay");
|
|
|
10
10
|
const userId_1 = require("./userId");
|
|
11
11
|
const DeviceInfoCollector_1 = require("./DeviceInfoCollector");
|
|
12
12
|
const EventManager_1 = require("./EventManager");
|
|
13
|
+
const RampKitNative_1 = require("./RampKitNative");
|
|
13
14
|
const constants_1 = require("./constants");
|
|
14
15
|
class RampKitCore {
|
|
15
16
|
constructor() {
|
|
@@ -51,6 +52,9 @@ class RampKitCore {
|
|
|
51
52
|
EventManager_1.eventManager.trackAppSessionStarted(this.deviceInfo.isFirstLaunch, this.deviceInfo.launchCount);
|
|
52
53
|
// Step 5: Setup app state listener for background/foreground tracking
|
|
53
54
|
this.setupAppStateListener();
|
|
55
|
+
// Step 6: Start transaction observer for automatic purchase tracking
|
|
56
|
+
console.log("[RampKit] Init: Starting transaction observer...");
|
|
57
|
+
await RampKitNative_1.TransactionObserver.start(config.appId);
|
|
54
58
|
this.initialized = true;
|
|
55
59
|
}
|
|
56
60
|
catch (e) {
|
|
@@ -276,46 +280,15 @@ class RampKitCore {
|
|
|
276
280
|
trackCtaTap(buttonId, buttonText) {
|
|
277
281
|
EventManager_1.eventManager.trackCtaTap(buttonId, buttonText);
|
|
278
282
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
trackPaywallShown(paywallId, placement, products) {
|
|
283
|
-
EventManager_1.eventManager.trackPaywallShown(paywallId, placement, products);
|
|
284
|
-
}
|
|
285
|
-
/**
|
|
286
|
-
* Track paywall primary action tap
|
|
287
|
-
*/
|
|
288
|
-
trackPaywallPrimaryActionTap(paywallId, productId) {
|
|
289
|
-
EventManager_1.eventManager.trackPaywallPrimaryActionTap(paywallId, productId);
|
|
290
|
-
}
|
|
291
|
-
/**
|
|
292
|
-
* Track paywall closed
|
|
293
|
-
*/
|
|
294
|
-
trackPaywallClosed(paywallId, reason) {
|
|
295
|
-
EventManager_1.eventManager.trackPaywallClosed(paywallId, reason);
|
|
296
|
-
}
|
|
297
|
-
/**
|
|
298
|
-
* Track purchase started
|
|
299
|
-
*/
|
|
300
|
-
trackPurchaseStarted(productId, amount, currency) {
|
|
301
|
-
EventManager_1.eventManager.trackPurchaseStarted(productId, amount, currency);
|
|
302
|
-
}
|
|
303
|
-
/**
|
|
304
|
-
* Track purchase completed
|
|
305
|
-
*/
|
|
306
|
-
trackPurchaseCompleted(properties) {
|
|
307
|
-
EventManager_1.eventManager.trackPurchaseCompleted(properties);
|
|
308
|
-
}
|
|
309
|
-
/**
|
|
310
|
-
* Track purchase failed
|
|
311
|
-
*/
|
|
312
|
-
trackPurchaseFailed(productId, errorCode, errorMessage) {
|
|
313
|
-
EventManager_1.eventManager.trackPurchaseFailed(productId, errorCode, errorMessage);
|
|
314
|
-
}
|
|
283
|
+
// Note: Purchase and paywall events are automatically tracked by the native
|
|
284
|
+
// StoreKit 2 (iOS) and Google Play Billing (Android) transaction observers.
|
|
285
|
+
// No manual tracking is needed.
|
|
315
286
|
/**
|
|
316
287
|
* Cleanup SDK resources
|
|
317
288
|
*/
|
|
318
|
-
cleanup() {
|
|
289
|
+
async cleanup() {
|
|
290
|
+
// Stop transaction observer
|
|
291
|
+
await RampKitNative_1.TransactionObserver.stop();
|
|
319
292
|
if (this.appStateSubscription) {
|
|
320
293
|
this.appStateSubscription.remove();
|
|
321
294
|
this.appStateSubscription = null;
|
package/build/RampKitNative.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ interface RampKitNativeModule {
|
|
|
16
16
|
getStoreUrl(): Promise<string | null>;
|
|
17
17
|
requestNotificationPermissions(options?: NotificationOptions): Promise<NotificationPermissionResult>;
|
|
18
18
|
getNotificationPermissions(): Promise<NotificationPermissionResult>;
|
|
19
|
+
startTransactionObserver(appId: string): Promise<void>;
|
|
20
|
+
stopTransactionObserver(): Promise<void>;
|
|
19
21
|
}
|
|
20
22
|
export interface NativeDeviceInfo {
|
|
21
23
|
appUserId: string;
|
|
@@ -149,3 +151,15 @@ export declare const Notifications: {
|
|
|
149
151
|
MIN: number;
|
|
150
152
|
};
|
|
151
153
|
};
|
|
154
|
+
export declare const TransactionObserver: {
|
|
155
|
+
/**
|
|
156
|
+
* Start listening for purchase transactions
|
|
157
|
+
* Automatically tracks purchases to the RampKit backend
|
|
158
|
+
* @param appId - The RampKit app ID
|
|
159
|
+
*/
|
|
160
|
+
start(appId: string): Promise<void>;
|
|
161
|
+
/**
|
|
162
|
+
* Stop listening for purchase transactions
|
|
163
|
+
*/
|
|
164
|
+
stop(): Promise<void>;
|
|
165
|
+
};
|
package/build/RampKitNative.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* TypeScript interface to the native iOS/Android module
|
|
5
5
|
*/
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
-
exports.Notifications = exports.StoreReview = exports.Haptics = void 0;
|
|
7
|
+
exports.TransactionObserver = exports.Notifications = exports.StoreReview = exports.Haptics = void 0;
|
|
8
8
|
exports.getDeviceInfo = getDeviceInfo;
|
|
9
9
|
exports.getUserId = getUserId;
|
|
10
10
|
exports.getStoredValue = getStoredValue;
|
|
@@ -60,6 +60,8 @@ function createFallbackModule() {
|
|
|
60
60
|
async getNotificationPermissions() {
|
|
61
61
|
return { granted: false, status: "denied", canAskAgain: false };
|
|
62
62
|
},
|
|
63
|
+
async startTransactionObserver(_appId) { },
|
|
64
|
+
async stopTransactionObserver() { },
|
|
63
65
|
};
|
|
64
66
|
}
|
|
65
67
|
function generateFallbackUserId() {
|
|
@@ -253,3 +255,34 @@ exports.Notifications = {
|
|
|
253
255
|
MIN: 1,
|
|
254
256
|
},
|
|
255
257
|
};
|
|
258
|
+
// ============================================================================
|
|
259
|
+
// Transaction Observer API (StoreKit 2 / Google Play Billing)
|
|
260
|
+
// ============================================================================
|
|
261
|
+
exports.TransactionObserver = {
|
|
262
|
+
/**
|
|
263
|
+
* Start listening for purchase transactions
|
|
264
|
+
* Automatically tracks purchases to the RampKit backend
|
|
265
|
+
* @param appId - The RampKit app ID
|
|
266
|
+
*/
|
|
267
|
+
async start(appId) {
|
|
268
|
+
try {
|
|
269
|
+
await RampKitNativeModule.startTransactionObserver(appId);
|
|
270
|
+
console.log("[RampKit] Transaction observer started");
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
console.warn("[RampKit] Failed to start transaction observer:", e);
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
/**
|
|
277
|
+
* Stop listening for purchase transactions
|
|
278
|
+
*/
|
|
279
|
+
async stop() {
|
|
280
|
+
try {
|
|
281
|
+
await RampKitNativeModule.stopTransactionObserver();
|
|
282
|
+
console.log("[RampKit] Transaction observer stopped");
|
|
283
|
+
}
|
|
284
|
+
catch (e) {
|
|
285
|
+
console.warn("[RampKit] Failed to stop transaction observer:", e);
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
};
|
package/build/RampkitOverlay.js
CHANGED
|
@@ -366,6 +366,11 @@ function Overlay(props) {
|
|
|
366
366
|
else if (typeof pager.setPageWithoutAnimation === "function") {
|
|
367
367
|
pager.setPageWithoutAnimation(nextIndex);
|
|
368
368
|
}
|
|
369
|
+
// Explicitly send vars to the new page after setting it
|
|
370
|
+
// This ensures the webview receives the latest state
|
|
371
|
+
requestAnimationFrame(() => {
|
|
372
|
+
sendVarsToWebView(nextIndex);
|
|
373
|
+
});
|
|
369
374
|
return;
|
|
370
375
|
}
|
|
371
376
|
setIsTransitioning(true);
|
|
@@ -380,6 +385,10 @@ function Overlay(props) {
|
|
|
380
385
|
// @ts-ignore: method exists on PagerView instance
|
|
381
386
|
(_c = (_b = (_a = pagerRef.current) === null || _a === void 0 ? void 0 : _a.setPageWithoutAnimation) === null || _b === void 0 ? void 0 : _b.call(_a, nextIndex)) !== null && _c !== void 0 ? _c : (_d = pagerRef.current) === null || _d === void 0 ? void 0 : _d.setPage(nextIndex);
|
|
382
387
|
requestAnimationFrame(() => {
|
|
388
|
+
// Explicitly send vars to the new page after the page switch completes
|
|
389
|
+
// This ensures the webview receives the latest state even if onPageSelected
|
|
390
|
+
// timing was off during the transition
|
|
391
|
+
sendVarsToWebView(nextIndex);
|
|
383
392
|
react_native_1.Animated.timing(fadeOpacity, {
|
|
384
393
|
toValue: 0,
|
|
385
394
|
duration: 160,
|
|
@@ -395,14 +404,20 @@ function Overlay(props) {
|
|
|
395
404
|
.replace(/`/g, "\\`");
|
|
396
405
|
return `(function(){try{document.dispatchEvent(new MessageEvent('message',{data:${json}}));}catch(e){}})();`;
|
|
397
406
|
}
|
|
398
|
-
function sendVarsToWebView(i) {
|
|
407
|
+
function sendVarsToWebView(i, isInitialLoad = false) {
|
|
399
408
|
const wv = webviewsRef.current[i];
|
|
400
409
|
if (!wv)
|
|
401
410
|
return;
|
|
402
411
|
const payload = { type: "rampkit:variables", vars: varsRef.current };
|
|
403
412
|
if (__DEV__)
|
|
404
|
-
console.log("[Rampkit] sendVarsToWebView", i, varsRef.current);
|
|
405
|
-
|
|
413
|
+
console.log("[Rampkit] sendVarsToWebView", i, varsRef.current, { isInitialLoad });
|
|
414
|
+
// Only update the stale filter timestamp during initial page load,
|
|
415
|
+
// not when syncing vars on page selection. This prevents the filter
|
|
416
|
+
// from incorrectly rejecting legitimate user interactions that happen
|
|
417
|
+
// immediately after navigating to a screen.
|
|
418
|
+
if (isInitialLoad) {
|
|
419
|
+
lastInitSendRef.current[i] = Date.now();
|
|
420
|
+
}
|
|
406
421
|
// @ts-ignore: injectJavaScript exists on WebView instance
|
|
407
422
|
wv.injectJavaScript(buildDispatchScript(payload));
|
|
408
423
|
}
|
|
@@ -470,7 +485,12 @@ function Overlay(props) {
|
|
|
470
485
|
// ensure current page is synced with latest vars when selected
|
|
471
486
|
if (__DEV__)
|
|
472
487
|
console.log("[Rampkit] onPageSelected", pos);
|
|
473
|
-
|
|
488
|
+
// Use requestAnimationFrame to ensure the webview is fully active and ready
|
|
489
|
+
// to receive injected JS. Without this delay, the first navigation back
|
|
490
|
+
// to a screen may not properly receive the updated variables.
|
|
491
|
+
requestAnimationFrame(() => {
|
|
492
|
+
sendVarsToWebView(pos);
|
|
493
|
+
});
|
|
474
494
|
// Track screen change event
|
|
475
495
|
if (props.onScreenChange && props.screens[pos]) {
|
|
476
496
|
props.onScreenChange(pos, props.screens[pos].id);
|
|
@@ -561,10 +581,10 @@ function Overlay(props) {
|
|
|
561
581
|
props.onScreenChange(0, props.screens[0].id);
|
|
562
582
|
}
|
|
563
583
|
}
|
|
564
|
-
// Initialize this page with current vars
|
|
584
|
+
// Initialize this page with current vars (isInitialLoad=true to enable stale filter)
|
|
565
585
|
if (__DEV__)
|
|
566
586
|
console.log("[Rampkit] onLoadEnd init send vars", i);
|
|
567
|
-
sendVarsToWebView(i);
|
|
587
|
+
sendVarsToWebView(i, true);
|
|
568
588
|
}, onMessage: (ev) => {
|
|
569
589
|
var _a, _b, _c, _d;
|
|
570
590
|
const raw = ev.nativeEvent.data;
|
package/build/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export { eventManager } from "./EventManager";
|
|
|
9
9
|
export { collectDeviceInfo, getSessionDurationSeconds, getSessionStartTime, } from "./DeviceInfoCollector";
|
|
10
10
|
export { default as RampKitNative } from "./RampKitNative";
|
|
11
11
|
export type { NativeDeviceInfo, NativeLaunchData } from "./RampKitNative";
|
|
12
|
-
export { Haptics, StoreReview, Notifications } from "./RampKitNative";
|
|
12
|
+
export { Haptics, StoreReview, Notifications, TransactionObserver } from "./RampKitNative";
|
|
13
13
|
export type { ImpactStyle, NotificationType, NotificationOptions, NotificationPermissionResult } from "./RampKitNative";
|
|
14
14
|
export type { DeviceInfo, RampKitEvent, EventDevice, EventContext, RampKitConfig, RampKitEventName, AppSessionStartedProperties, AppSessionEndedProperties, AppBackgroundedProperties, AppForegroundedProperties, OnboardingStartedProperties, OnboardingScreenViewedProperties, OnboardingQuestionAnsweredProperties, OnboardingCompletedProperties, OnboardingAbandonedProperties, ScreenViewProperties, CtaTapProperties, NotificationsPromptShownProperties, NotificationsResponseProperties, PaywallShownProperties, PaywallPrimaryActionTapProperties, PaywallClosedProperties, PurchaseStartedProperties, PurchaseCompletedProperties, PurchaseFailedProperties, } from "./types";
|
|
15
15
|
export { SDK_VERSION, CAPABILITIES } from "./constants";
|
package/build/index.js
CHANGED
|
@@ -7,7 +7,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
7
7
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
8
8
|
};
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
-
exports.CAPABILITIES = exports.SDK_VERSION = exports.Notifications = exports.StoreReview = exports.Haptics = exports.RampKitNative = exports.getSessionStartTime = exports.getSessionDurationSeconds = exports.collectDeviceInfo = exports.eventManager = exports.getRampKitUserId = exports.RampKit = void 0;
|
|
10
|
+
exports.CAPABILITIES = exports.SDK_VERSION = exports.TransactionObserver = exports.Notifications = exports.StoreReview = exports.Haptics = exports.RampKitNative = exports.getSessionStartTime = exports.getSessionDurationSeconds = exports.collectDeviceInfo = exports.eventManager = exports.getRampKitUserId = exports.RampKit = void 0;
|
|
11
11
|
const RampKit_1 = require("./RampKit");
|
|
12
12
|
// Main SDK singleton instance
|
|
13
13
|
exports.RampKit = RampKit_1.RampKitCore.instance;
|
|
@@ -30,6 +30,7 @@ var RampKitNative_2 = require("./RampKitNative");
|
|
|
30
30
|
Object.defineProperty(exports, "Haptics", { enumerable: true, get: function () { return RampKitNative_2.Haptics; } });
|
|
31
31
|
Object.defineProperty(exports, "StoreReview", { enumerable: true, get: function () { return RampKitNative_2.StoreReview; } });
|
|
32
32
|
Object.defineProperty(exports, "Notifications", { enumerable: true, get: function () { return RampKitNative_2.Notifications; } });
|
|
33
|
+
Object.defineProperty(exports, "TransactionObserver", { enumerable: true, get: function () { return RampKitNative_2.TransactionObserver; } });
|
|
33
34
|
// Export constants
|
|
34
35
|
var constants_1 = require("./constants");
|
|
35
36
|
Object.defineProperty(exports, "SDK_VERSION", { enumerable: true, get: function () { return constants_1.SDK_VERSION; } });
|
package/ios/RampKitModule.swift
CHANGED
|
@@ -10,6 +10,11 @@ public class RampKitModule: Module {
|
|
|
10
10
|
private let launchCountKey = "rk_launch_count"
|
|
11
11
|
private let lastLaunchKey = "rk_last_launch"
|
|
12
12
|
|
|
13
|
+
// Transaction observer task
|
|
14
|
+
private var transactionObserverTask: Task<Void, Never>?
|
|
15
|
+
private var appId: String?
|
|
16
|
+
private var userId: String?
|
|
17
|
+
|
|
13
18
|
public func definition() -> ModuleDefinition {
|
|
14
19
|
Name("RampKit")
|
|
15
20
|
|
|
@@ -62,11 +67,10 @@ public class RampKitModule: Module {
|
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
AsyncFunction("isReviewAvailable") { () -> Bool in
|
|
65
|
-
return true
|
|
70
|
+
return true
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
AsyncFunction("getStoreUrl") { () -> String? in
|
|
69
|
-
// Return nil - app should provide its own store URL
|
|
70
74
|
return nil
|
|
71
75
|
}
|
|
72
76
|
|
|
@@ -81,6 +85,18 @@ public class RampKitModule: Module {
|
|
|
81
85
|
AsyncFunction("getNotificationPermissions") { () -> [String: Any] in
|
|
82
86
|
return await self.getNotificationPermissions()
|
|
83
87
|
}
|
|
88
|
+
|
|
89
|
+
// ============================================================================
|
|
90
|
+
// Transaction Observer (StoreKit 2)
|
|
91
|
+
// ============================================================================
|
|
92
|
+
|
|
93
|
+
AsyncFunction("startTransactionObserver") { (appId: String) in
|
|
94
|
+
self.startTransactionObserver(appId: appId)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
AsyncFunction("stopTransactionObserver") { () in
|
|
98
|
+
self.stopTransactionObserver()
|
|
99
|
+
}
|
|
84
100
|
}
|
|
85
101
|
|
|
86
102
|
// MARK: - Device Info Collection
|
|
@@ -369,6 +385,205 @@ public class RampKitModule: Module {
|
|
|
369
385
|
}
|
|
370
386
|
}
|
|
371
387
|
|
|
388
|
+
// MARK: - StoreKit 2 Transaction Observer
|
|
389
|
+
|
|
390
|
+
private func startTransactionObserver(appId: String) {
|
|
391
|
+
self.appId = appId
|
|
392
|
+
self.userId = getOrCreateUserId()
|
|
393
|
+
|
|
394
|
+
// Cancel existing observer if any
|
|
395
|
+
transactionObserverTask?.cancel()
|
|
396
|
+
|
|
397
|
+
// Start listening for transactions (iOS 15+)
|
|
398
|
+
if #available(iOS 15.0, *) {
|
|
399
|
+
transactionObserverTask = Task {
|
|
400
|
+
await self.listenForTransactions()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Also check for any unfinished transactions
|
|
404
|
+
Task {
|
|
405
|
+
await self.handleUnfinishedTransactions()
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
print("[RampKit] Transaction observer started")
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private func stopTransactionObserver() {
|
|
413
|
+
transactionObserverTask?.cancel()
|
|
414
|
+
transactionObserverTask = nil
|
|
415
|
+
print("[RampKit] Transaction observer stopped")
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
@available(iOS 15.0, *)
|
|
419
|
+
private func listenForTransactions() async {
|
|
420
|
+
// Listen for transaction updates
|
|
421
|
+
for await result in Transaction.updates {
|
|
422
|
+
do {
|
|
423
|
+
let transaction = try self.checkVerified(result)
|
|
424
|
+
await self.handleTransaction(transaction)
|
|
425
|
+
await transaction.finish()
|
|
426
|
+
} catch {
|
|
427
|
+
print("[RampKit] Transaction verification failed: \(error)")
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
@available(iOS 15.0, *)
|
|
433
|
+
private func handleUnfinishedTransactions() async {
|
|
434
|
+
// Handle any transactions that weren't finished
|
|
435
|
+
for await result in Transaction.unfinished {
|
|
436
|
+
do {
|
|
437
|
+
let transaction = try self.checkVerified(result)
|
|
438
|
+
await self.handleTransaction(transaction)
|
|
439
|
+
await transaction.finish()
|
|
440
|
+
} catch {
|
|
441
|
+
print("[RampKit] Unfinished transaction verification failed: \(error)")
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
@available(iOS 15.0, *)
|
|
447
|
+
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
|
|
448
|
+
switch result {
|
|
449
|
+
case .unverified(_, let error):
|
|
450
|
+
throw error
|
|
451
|
+
case .verified(let safe):
|
|
452
|
+
return safe
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
@available(iOS 15.0, *)
|
|
457
|
+
private func handleTransaction(_ transaction: Transaction) async {
|
|
458
|
+
guard let appId = self.appId, let userId = self.userId else { return }
|
|
459
|
+
|
|
460
|
+
// Determine event type based on transaction
|
|
461
|
+
let eventName: String
|
|
462
|
+
var properties: [String: Any] = [:]
|
|
463
|
+
|
|
464
|
+
switch transaction.revocationReason {
|
|
465
|
+
case .some(let reason):
|
|
466
|
+
eventName = "purchase_refunded"
|
|
467
|
+
properties["revocationReason"] = reason == .developerIssue ? "developer_issue" : "other"
|
|
468
|
+
case .none:
|
|
469
|
+
if transaction.isUpgraded {
|
|
470
|
+
eventName = "subscription_upgraded"
|
|
471
|
+
} else {
|
|
472
|
+
switch transaction.productType {
|
|
473
|
+
case .autoRenewable:
|
|
474
|
+
if transaction.originalID == transaction.id {
|
|
475
|
+
eventName = "purchase_completed"
|
|
476
|
+
} else {
|
|
477
|
+
eventName = "subscription_renewed"
|
|
478
|
+
}
|
|
479
|
+
case .consumable, .nonConsumable:
|
|
480
|
+
eventName = "purchase_completed"
|
|
481
|
+
case .nonRenewable:
|
|
482
|
+
eventName = "purchase_completed"
|
|
483
|
+
default:
|
|
484
|
+
eventName = "purchase_completed"
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Build properties
|
|
490
|
+
properties["productId"] = transaction.productID
|
|
491
|
+
properties["transactionId"] = String(transaction.id)
|
|
492
|
+
properties["originalTransactionId"] = String(transaction.originalID)
|
|
493
|
+
properties["purchaseDate"] = ISO8601DateFormatter().string(from: transaction.purchaseDate)
|
|
494
|
+
|
|
495
|
+
if let expirationDate = transaction.expirationDate {
|
|
496
|
+
properties["expirationDate"] = ISO8601DateFormatter().string(from: expirationDate)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
properties["quantity"] = transaction.purchasedQuantity
|
|
500
|
+
properties["productType"] = mapProductType(transaction.productType)
|
|
501
|
+
properties["environment"] = transaction.environment.rawValue
|
|
502
|
+
|
|
503
|
+
if let webOrderLineItemID = transaction.webOrderLineItemID {
|
|
504
|
+
properties["webOrderLineItemId"] = webOrderLineItemID
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Get price info from product if available
|
|
508
|
+
if let product = await getProduct(for: transaction.productID) {
|
|
509
|
+
properties["price"] = product.price
|
|
510
|
+
properties["currency"] = product.priceFormatStyle.currencyCode
|
|
511
|
+
properties["localizedPrice"] = product.displayPrice
|
|
512
|
+
properties["localizedName"] = product.displayName
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Send event to backend
|
|
516
|
+
await sendPurchaseEvent(
|
|
517
|
+
appId: appId,
|
|
518
|
+
userId: userId,
|
|
519
|
+
eventName: eventName,
|
|
520
|
+
properties: properties
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
@available(iOS 15.0, *)
|
|
525
|
+
private func getProduct(for productId: String) async -> Product? {
|
|
526
|
+
do {
|
|
527
|
+
let products = try await Product.products(for: [productId])
|
|
528
|
+
return products.first
|
|
529
|
+
} catch {
|
|
530
|
+
return nil
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
@available(iOS 15.0, *)
|
|
535
|
+
private func mapProductType(_ type: Product.ProductType) -> String {
|
|
536
|
+
switch type {
|
|
537
|
+
case .consumable: return "consumable"
|
|
538
|
+
case .nonConsumable: return "non_consumable"
|
|
539
|
+
case .autoRenewable: return "auto_renewable"
|
|
540
|
+
case .nonRenewable: return "non_renewable"
|
|
541
|
+
@unknown default: return "unknown"
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private func sendPurchaseEvent(appId: String, userId: String, eventName: String, properties: [String: Any]) async {
|
|
546
|
+
let event: [String: Any] = [
|
|
547
|
+
"appId": appId,
|
|
548
|
+
"appUserId": userId,
|
|
549
|
+
"eventId": UUID().uuidString.lowercased(),
|
|
550
|
+
"eventName": eventName,
|
|
551
|
+
"sessionId": UUID().uuidString.lowercased(),
|
|
552
|
+
"occurredAt": ISO8601DateFormatter().string(from: Date()),
|
|
553
|
+
"device": [
|
|
554
|
+
"platform": isPad() ? "iPadOS" : "iOS",
|
|
555
|
+
"platformVersion": UIDevice.current.systemVersion,
|
|
556
|
+
"deviceModel": getDeviceModelIdentifier(),
|
|
557
|
+
"sdkVersion": "1.0.0",
|
|
558
|
+
"appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String,
|
|
559
|
+
"buildNumber": Bundle.main.infoDictionary?["CFBundleVersion"] as? String
|
|
560
|
+
],
|
|
561
|
+
"context": [
|
|
562
|
+
"locale": Locale.current.identifier,
|
|
563
|
+
"regionCode": Locale.current.regionCode
|
|
564
|
+
],
|
|
565
|
+
"properties": properties
|
|
566
|
+
]
|
|
567
|
+
|
|
568
|
+
guard let url = URL(string: "https://uustlzuvjmochxkxatfx.supabase.co/functions/v1/app-user-events") else { return }
|
|
569
|
+
|
|
570
|
+
var request = URLRequest(url: url)
|
|
571
|
+
request.httpMethod = "POST"
|
|
572
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
573
|
+
request.setValue("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV1c3RsenV2am1vY2h4a3hhdGZ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzU1NjQ0NjYsImV4cCI6MjA1MTE0MDQ2Nn0.5cNrph5LHmssNo39UKpULkC9n4OD5n6gsnTEQV-gwQk", forHTTPHeaderField: "apikey")
|
|
574
|
+
request.setValue("Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InV1c3RsenV2am1vY2h4a3hhdGZ4Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzU1NjQ0NjYsImV4cCI6MjA1MTE0MDQ2Nn0.5cNrph5LHmssNo39UKpULkC9n4OD5n6gsnTEQV-gwQk", forHTTPHeaderField: "Authorization")
|
|
575
|
+
|
|
576
|
+
do {
|
|
577
|
+
request.httpBody = try JSONSerialization.data(withJSONObject: event)
|
|
578
|
+
let (_, response) = try await URLSession.shared.data(for: request)
|
|
579
|
+
if let httpResponse = response as? HTTPURLResponse {
|
|
580
|
+
print("[RampKit] Purchase event sent: \(eventName) - Status: \(httpResponse.statusCode)")
|
|
581
|
+
}
|
|
582
|
+
} catch {
|
|
583
|
+
print("[RampKit] Failed to send purchase event: \(error)")
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
372
587
|
// MARK: - Device Helpers
|
|
373
588
|
|
|
374
589
|
private func getDeviceModelIdentifier() -> String {
|
package/package.json
CHANGED