@virex-tech/paywallo-sdk 1.5.4 → 2.0.1
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/PaywalloSdk.podspec +1 -1
- package/android/build.gradle +2 -1
- package/android/src/main/AndroidManifest.xml +21 -0
- package/android/src/main/java/com/paywallo/sdk/PaywalloFirebaseMessagingService.kt +67 -0
- package/android/src/main/java/com/paywallo/sdk/PaywalloNotificationsModule.kt +169 -0
- package/android/src/main/java/com/paywallo/sdk/PaywalloSdkPackage.kt +2 -1
- package/android/src/main/java/com/paywallo/sdk/PaywalloStoreKitModule.kt +103 -0
- package/dist/index.d.mts +560 -39
- package/dist/index.d.ts +560 -39
- package/dist/index.js +6843 -3709
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6832 -3700
- package/dist/index.mjs.map +1 -1
- package/ios/PaywalloNotifications.m +22 -0
- package/ios/PaywalloNotifications.swift +250 -0
- package/ios/PaywalloStoreKit.m +4 -1
- package/ios/PaywalloStoreKit.swift +152 -8
- package/ios/PaywalloWebView.m +2 -3
- package/package.json +11 -4
package/PaywalloSdk.podspec
CHANGED
|
@@ -12,7 +12,7 @@ Pod::Spec.new do |s|
|
|
|
12
12
|
s.platforms = { :ios => "15.0" }
|
|
13
13
|
s.source = { :git => "https://github.com/paywallo/sdk-react-native.git", :tag => "v#{s.version}" }
|
|
14
14
|
s.source_files = "ios/**/*.{h,m,mm,swift}"
|
|
15
|
-
s.frameworks = "Security", "StoreKit", "WebKit", "CoreTelephony", "AVFoundation"
|
|
15
|
+
s.frameworks = "Security", "StoreKit", "WebKit", "CoreTelephony", "AVFoundation", "UserNotifications"
|
|
16
16
|
s.swift_version = "5.5"
|
|
17
17
|
|
|
18
18
|
s.dependency "React-Core"
|
package/android/build.gradle
CHANGED
|
@@ -7,7 +7,7 @@ buildscript {
|
|
|
7
7
|
mavenCentral()
|
|
8
8
|
}
|
|
9
9
|
dependencies {
|
|
10
|
-
classpath("com.android.tools.build:gradle
|
|
10
|
+
classpath("com.android.tools.build:gradle:${safeExtGet('androidGradlePluginVersion', '7.4.2')}")
|
|
11
11
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${safeExtGet('kotlinVersion', '1.9.22')}")
|
|
12
12
|
}
|
|
13
13
|
}
|
|
@@ -53,6 +53,7 @@ dependencies {
|
|
|
53
53
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"
|
|
54
54
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
|
55
55
|
compileOnly "com.facebook.android:facebook-android-sdk:17.+"
|
|
56
|
+
compileOnly "com.google.firebase:firebase-messaging:24.0.0"
|
|
56
57
|
implementation "androidx.security:security-crypto:1.0.0"
|
|
57
58
|
implementation "com.android.installreferrer:installreferrer:2.2"
|
|
58
59
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
2
|
+
package="com.paywallo.sdk">
|
|
3
|
+
|
|
4
|
+
<application>
|
|
5
|
+
<!--
|
|
6
|
+
PaywalloFirebaseMessagingService handles FCM token refresh and message delivery.
|
|
7
|
+
If the host app declares its own FirebaseMessagingService, Android will only
|
|
8
|
+
invoke ONE service (the one declared in the final merged manifest, typically
|
|
9
|
+
the host app's). In that case, the host app's service must forward
|
|
10
|
+
onNewToken/onMessageReceived to PaywalloFirebaseMessagingService manually,
|
|
11
|
+
or use Firebase's automatic message routing with multiple services.
|
|
12
|
+
-->
|
|
13
|
+
<service
|
|
14
|
+
android:name=".PaywalloFirebaseMessagingService"
|
|
15
|
+
android:exported="false">
|
|
16
|
+
<intent-filter>
|
|
17
|
+
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
|
18
|
+
</intent-filter>
|
|
19
|
+
</service>
|
|
20
|
+
</application>
|
|
21
|
+
</manifest>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
package com.paywallo.sdk
|
|
2
|
+
|
|
3
|
+
import com.google.firebase.messaging.FirebaseMessagingService
|
|
4
|
+
import com.google.firebase.messaging.RemoteMessage
|
|
5
|
+
import com.facebook.react.bridge.WritableNativeMap
|
|
6
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
7
|
+
import com.facebook.react.ReactApplication
|
|
8
|
+
|
|
9
|
+
class PaywalloFirebaseMessagingService : FirebaseMessagingService() {
|
|
10
|
+
|
|
11
|
+
companion object {
|
|
12
|
+
const val TOKEN_REFRESH_EVENT = "PaywalloTokenRefresh"
|
|
13
|
+
const val MESSAGE_RECEIVED_EVENT = "PaywalloMessageReceived"
|
|
14
|
+
|
|
15
|
+
@Volatile
|
|
16
|
+
var latestToken: String? = null
|
|
17
|
+
|
|
18
|
+
@Volatile
|
|
19
|
+
var pendingMessage: WritableNativeMap? = null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
override fun onNewToken(token: String) {
|
|
23
|
+
super.onNewToken(token)
|
|
24
|
+
latestToken = token
|
|
25
|
+
emitEvent(TOKEN_REFRESH_EVENT, WritableNativeMap().apply {
|
|
26
|
+
putString("token", token)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override fun onMessageReceived(message: RemoteMessage) {
|
|
31
|
+
super.onMessageReceived(message)
|
|
32
|
+
val map = WritableNativeMap().apply {
|
|
33
|
+
putString("messageId", message.messageId ?: "")
|
|
34
|
+
message.notification?.let { n ->
|
|
35
|
+
putString("title", n.title ?: "")
|
|
36
|
+
putString("body", n.body ?: "")
|
|
37
|
+
putString("imageUrl", n.imageUrl?.toString())
|
|
38
|
+
}
|
|
39
|
+
val dataMap = WritableNativeMap()
|
|
40
|
+
message.data.forEach { (k, v) -> dataMap.putString(k, v) }
|
|
41
|
+
putMap("data", dataMap)
|
|
42
|
+
putDouble("sentTime", message.sentTime.toDouble())
|
|
43
|
+
}
|
|
44
|
+
emitEvent(MESSAGE_RECEIVED_EVENT, map)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private fun emitEvent(eventName: String, data: WritableNativeMap) {
|
|
48
|
+
try {
|
|
49
|
+
val reactContext = (application as? ReactApplication)
|
|
50
|
+
?.reactNativeHost
|
|
51
|
+
?.reactInstanceManager
|
|
52
|
+
?.currentReactContext
|
|
53
|
+
if (reactContext != null) {
|
|
54
|
+
reactContext
|
|
55
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
56
|
+
.emit(eventName, data)
|
|
57
|
+
} else if (eventName == MESSAGE_RECEIVED_EVENT) {
|
|
58
|
+
// Cold start: React context not ready — buffer for getInitialNotification()
|
|
59
|
+
pendingMessage = data
|
|
60
|
+
}
|
|
61
|
+
} catch (_: Exception) {
|
|
62
|
+
if (eventName == MESSAGE_RECEIVED_EVENT) {
|
|
63
|
+
pendingMessage = data
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
package com.paywallo.sdk
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.content.pm.PackageManager
|
|
5
|
+
import android.os.Build
|
|
6
|
+
import androidx.core.app.ActivityCompat
|
|
7
|
+
import androidx.core.content.ContextCompat
|
|
8
|
+
import com.facebook.react.bridge.*
|
|
9
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
10
|
+
|
|
11
|
+
class PaywalloNotificationsModule(private val reactContext: ReactApplicationContext) :
|
|
12
|
+
ReactContextBaseJavaModule(reactContext) {
|
|
13
|
+
|
|
14
|
+
private var permissionPromise: Promise? = null
|
|
15
|
+
private val PERMISSION_REQUEST_CODE = 1001
|
|
16
|
+
|
|
17
|
+
private val activityEventListener = object : BaseActivityEventListener() {
|
|
18
|
+
override fun onRequestPermissionsResult(
|
|
19
|
+
requestCode: Int,
|
|
20
|
+
permissions: Array<out String>,
|
|
21
|
+
grantResults: IntArray
|
|
22
|
+
) {
|
|
23
|
+
if (requestCode != PERMISSION_REQUEST_CODE) return
|
|
24
|
+
val promise = permissionPromise ?: return
|
|
25
|
+
permissionPromise = null
|
|
26
|
+
|
|
27
|
+
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
|
28
|
+
promise.resolve("granted")
|
|
29
|
+
} else {
|
|
30
|
+
promise.resolve("denied")
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
init {
|
|
36
|
+
reactContext.addActivityEventListener(activityEventListener)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
override fun getName() = "PaywalloNotifications"
|
|
40
|
+
|
|
41
|
+
// Required for NativeEventEmitter on Android (silences the warning and
|
|
42
|
+
// gives RN the hook it needs to wire listeners).
|
|
43
|
+
@ReactMethod
|
|
44
|
+
fun addListener(eventName: String) {}
|
|
45
|
+
|
|
46
|
+
@ReactMethod
|
|
47
|
+
fun removeListeners(count: Int) {}
|
|
48
|
+
|
|
49
|
+
@ReactMethod
|
|
50
|
+
fun getToken(promise: Promise) {
|
|
51
|
+
// First check static holder from FirebaseMessagingService
|
|
52
|
+
val cached = PaywalloFirebaseMessagingService.latestToken
|
|
53
|
+
if (cached != null) {
|
|
54
|
+
promise.resolve(cached)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
// Fallback: ask Firebase directly.
|
|
58
|
+
// Catch Throwable (not just Exception) to handle NoClassDefFoundError when
|
|
59
|
+
// firebase-messaging is absent at runtime (compileOnly dependency).
|
|
60
|
+
try {
|
|
61
|
+
com.google.firebase.messaging.FirebaseMessaging.getInstance().token
|
|
62
|
+
.addOnCompleteListener { task ->
|
|
63
|
+
if (task.isSuccessful) {
|
|
64
|
+
val token = task.result
|
|
65
|
+
PaywalloFirebaseMessagingService.latestToken = token
|
|
66
|
+
promise.resolve(token)
|
|
67
|
+
} else {
|
|
68
|
+
android.util.Log.d("PaywalloNotifications", "FCM token request failed: ${task.exception?.message}")
|
|
69
|
+
promise.resolve(null)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch (e: Throwable) {
|
|
73
|
+
android.util.Log.d("PaywalloNotifications", "Firebase not available: ${e.message}")
|
|
74
|
+
promise.resolve(null)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@ReactMethod
|
|
79
|
+
fun requestPermission(options: ReadableMap?, promise: Promise) {
|
|
80
|
+
// Android 13+ (API 33) requires POST_NOTIFICATIONS runtime permission
|
|
81
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
82
|
+
val activity = currentActivity
|
|
83
|
+
if (activity == null) {
|
|
84
|
+
promise.resolve("denied")
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
val granted = ContextCompat.checkSelfPermission(
|
|
88
|
+
activity, Manifest.permission.POST_NOTIFICATIONS
|
|
89
|
+
) == PackageManager.PERMISSION_GRANTED
|
|
90
|
+
|
|
91
|
+
if (granted) {
|
|
92
|
+
promise.resolve("granted")
|
|
93
|
+
} else {
|
|
94
|
+
// If there's already a pending promise, reject it before storing the new one
|
|
95
|
+
permissionPromise?.reject("PERMISSION_SUPERSEDED", "New permission request superseded this one")
|
|
96
|
+
// Store promise and wait for onRequestPermissionsResult callback
|
|
97
|
+
permissionPromise = promise
|
|
98
|
+
ActivityCompat.requestPermissions(
|
|
99
|
+
activity,
|
|
100
|
+
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
|
101
|
+
PERMISSION_REQUEST_CODE
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
// Pre-Android 13: notifications always allowed
|
|
106
|
+
promise.resolve("granted")
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@ReactMethod
|
|
111
|
+
fun hasPermission(promise: Promise) {
|
|
112
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
113
|
+
val granted = ContextCompat.checkSelfPermission(
|
|
114
|
+
reactContext, Manifest.permission.POST_NOTIFICATIONS
|
|
115
|
+
) == PackageManager.PERMISSION_GRANTED
|
|
116
|
+
promise.resolve(if (granted) "granted" else "denied")
|
|
117
|
+
} else {
|
|
118
|
+
promise.resolve("granted")
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@ReactMethod
|
|
123
|
+
fun getInitialNotification(promise: Promise) {
|
|
124
|
+
// Check buffered data-only message from cold start first
|
|
125
|
+
val pending = PaywalloFirebaseMessagingService.pendingMessage
|
|
126
|
+
if (pending != null) {
|
|
127
|
+
PaywalloFirebaseMessagingService.pendingMessage = null
|
|
128
|
+
promise.resolve(pending)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Check if the app was opened from a notification tap
|
|
133
|
+
val activity = currentActivity
|
|
134
|
+
val intent = activity?.intent
|
|
135
|
+
val extras = intent?.extras
|
|
136
|
+
|
|
137
|
+
if (extras != null && extras.containsKey("google.message_id")) {
|
|
138
|
+
val map = WritableNativeMap()
|
|
139
|
+
map.putString("messageId", extras.getString("google.message_id", ""))
|
|
140
|
+
map.putString("title", extras.getString("gcm.notification.title", ""))
|
|
141
|
+
map.putString("body", extras.getString("gcm.notification.body", ""))
|
|
142
|
+
|
|
143
|
+
val data = WritableNativeMap()
|
|
144
|
+
for (key in extras.keySet()) {
|
|
145
|
+
if (!key.startsWith("google.") && !key.startsWith("gcm.") && key != "from" && key != "collapse_key") {
|
|
146
|
+
val value = extras.getString(key)
|
|
147
|
+
if (value != null) data.putString(key, value)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
map.putMap("data", data)
|
|
151
|
+
|
|
152
|
+
// Consume once — replace the intent's extras entirely so
|
|
153
|
+
// subsequent calls to getInitialNotification() return null.
|
|
154
|
+
// intent.removeExtra() mutates the same Bundle object in place
|
|
155
|
+
// but the Activity retains the original intent; replacing the
|
|
156
|
+
// extras Bundle is the only reliable way to prevent double-delivery.
|
|
157
|
+
intent.replaceExtras(android.os.Bundle())
|
|
158
|
+
promise.resolve(map)
|
|
159
|
+
} else {
|
|
160
|
+
promise.resolve(null)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
override fun onCatalystInstanceDestroy() {
|
|
165
|
+
reactContext.removeActivityEventListener(activityEventListener)
|
|
166
|
+
permissionPromise = null
|
|
167
|
+
super.onCatalystInstanceDestroy()
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -11,7 +11,8 @@ class PaywalloSdkPackage : ReactPackage {
|
|
|
11
11
|
PaywalloStoreKitModule(reactContext),
|
|
12
12
|
PaywalloFBBridgeModule(reactContext),
|
|
13
13
|
PaywalloStorageModule(reactContext),
|
|
14
|
-
PaywalloDeviceModule(reactContext)
|
|
14
|
+
PaywalloDeviceModule(reactContext),
|
|
15
|
+
PaywalloNotificationsModule(reactContext)
|
|
15
16
|
)
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -3,6 +3,7 @@ package com.paywallo.sdk
|
|
|
3
3
|
import android.app.Activity
|
|
4
4
|
import com.android.billingclient.api.*
|
|
5
5
|
import com.facebook.react.bridge.*
|
|
6
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
6
7
|
import kotlinx.coroutines.*
|
|
7
8
|
|
|
8
9
|
class PaywalloStoreKitModule(private val reactContext: ReactApplicationContext) :
|
|
@@ -12,8 +13,45 @@ class PaywalloStoreKitModule(private val reactContext: ReactApplicationContext)
|
|
|
12
13
|
private var purchasePromise: Promise? = null
|
|
13
14
|
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
14
15
|
|
|
16
|
+
// Tokens seen in the last getActiveTransactions() call. Used to detect
|
|
17
|
+
// refunds: a token that was active before but is missing on the next
|
|
18
|
+
// query has been refunded/revoked server-side and we surface it to JS as
|
|
19
|
+
// a `refunded` transaction update.
|
|
20
|
+
private val knownActiveTokens = mutableSetOf<String>()
|
|
21
|
+
|
|
22
|
+
companion object {
|
|
23
|
+
private const val TRANSACTION_UPDATE_EVENT = "PaywalloTransactionUpdate"
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
override fun getName() = "PaywalloStoreKit"
|
|
16
27
|
|
|
28
|
+
// Required for NativeEventEmitter on Android (silences the warning and
|
|
29
|
+
// gives RN the hook it needs to wire listeners).
|
|
30
|
+
@ReactMethod
|
|
31
|
+
fun addListener(eventName: String) {}
|
|
32
|
+
|
|
33
|
+
@ReactMethod
|
|
34
|
+
fun removeListeners(count: Int) {}
|
|
35
|
+
|
|
36
|
+
private fun emitTransactionUpdate(
|
|
37
|
+
type: String,
|
|
38
|
+
transactionId: String,
|
|
39
|
+
productId: String,
|
|
40
|
+
amount: Double? = null,
|
|
41
|
+
currency: String? = null
|
|
42
|
+
) {
|
|
43
|
+
val map = WritableNativeMap()
|
|
44
|
+
map.putString("type", type)
|
|
45
|
+
map.putString("transactionId", transactionId)
|
|
46
|
+
map.putString("productId", productId)
|
|
47
|
+
if (amount != null) map.putDouble("amount", amount)
|
|
48
|
+
if (currency != null) map.putString("currency", currency)
|
|
49
|
+
|
|
50
|
+
reactContext
|
|
51
|
+
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
52
|
+
.emit(TRANSACTION_UPDATE_EVENT, map)
|
|
53
|
+
}
|
|
54
|
+
|
|
17
55
|
private fun ensureBillingClient(): BillingClient {
|
|
18
56
|
if (billingClient == null || billingClient?.isReady != true) {
|
|
19
57
|
billingClient = BillingClient.newBuilder(reactContext)
|
|
@@ -206,6 +244,42 @@ class PaywalloStoreKitModule(private val reactContext: ReactApplicationContext)
|
|
|
206
244
|
}
|
|
207
245
|
|
|
208
246
|
override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
|
|
247
|
+
// ITEM_ALREADY_OWNED is Google Play's signal for "this user is already
|
|
248
|
+
// subscribed" — which on auto-renewing subs also fires when the
|
|
249
|
+
// backend renews the entitlement. We surface it as `renewed` so the
|
|
250
|
+
// EventBatcher pipeline gets parity with StoreKit 2.
|
|
251
|
+
if (result.responseCode == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) {
|
|
252
|
+
scope.launch {
|
|
253
|
+
try {
|
|
254
|
+
val client = billingClient ?: return@launch
|
|
255
|
+
// Query both SUBS and INAPP — ITEM_ALREADY_OWNED fires for
|
|
256
|
+
// one-time purchases (consumables not yet consumed) as well
|
|
257
|
+
// as auto-renewing subscriptions. Checking only SUBS would
|
|
258
|
+
// silently drop INAPP renewal recognition.
|
|
259
|
+
val activeSubs = client.queryPurchasesAsync(
|
|
260
|
+
QueryPurchasesParams.newBuilder()
|
|
261
|
+
.setProductType(BillingClient.ProductType.SUBS)
|
|
262
|
+
.build()
|
|
263
|
+
)
|
|
264
|
+
val activeInapp = client.queryPurchasesAsync(
|
|
265
|
+
QueryPurchasesParams.newBuilder()
|
|
266
|
+
.setProductType(BillingClient.ProductType.INAPP)
|
|
267
|
+
.build()
|
|
268
|
+
)
|
|
269
|
+
val allPurchases = activeSubs.purchasesList + activeInapp.purchasesList
|
|
270
|
+
allPurchases.forEach { p ->
|
|
271
|
+
if (p.purchaseState == Purchase.PurchaseState.PURCHASED) {
|
|
272
|
+
emitTransactionUpdate(
|
|
273
|
+
type = "renewed",
|
|
274
|
+
transactionId = p.orderId ?: p.purchaseToken,
|
|
275
|
+
productId = p.products.firstOrNull() ?: ""
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} catch (_: Exception) { }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
209
283
|
val promise = purchasePromise ?: return
|
|
210
284
|
purchasePromise = null
|
|
211
285
|
|
|
@@ -214,6 +288,11 @@ class PaywalloStoreKitModule(private val reactContext: ReactApplicationContext)
|
|
|
214
288
|
val purchase = purchases?.firstOrNull()
|
|
215
289
|
if (purchase != null) {
|
|
216
290
|
if (purchase.purchaseState == Purchase.PurchaseState.PENDING) {
|
|
291
|
+
emitTransactionUpdate(
|
|
292
|
+
type = "pending",
|
|
293
|
+
transactionId = purchase.orderId ?: purchase.purchaseToken,
|
|
294
|
+
productId = purchase.products.firstOrNull() ?: ""
|
|
295
|
+
)
|
|
217
296
|
val map = WritableNativeMap()
|
|
218
297
|
map.putBoolean("pending", true)
|
|
219
298
|
promise.resolve(map)
|
|
@@ -232,6 +311,11 @@ class PaywalloStoreKitModule(private val reactContext: ReactApplicationContext)
|
|
|
232
311
|
BillingClient.BillingResponseCode.USER_CANCELED -> {
|
|
233
312
|
promise.resolve(null)
|
|
234
313
|
}
|
|
314
|
+
BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> {
|
|
315
|
+
// Renewal path: resolve null so JS doesn't crash and let the
|
|
316
|
+
// emitted `renewed` event drive the analytics update.
|
|
317
|
+
promise.resolve(null)
|
|
318
|
+
}
|
|
235
319
|
else -> {
|
|
236
320
|
promise.reject("PURCHASE_ERROR", "Purchase failed: ${result.debugMessage}")
|
|
237
321
|
}
|
|
@@ -306,8 +390,10 @@ class PaywalloStoreKitModule(private val reactContext: ReactApplicationContext)
|
|
|
306
390
|
.build()
|
|
307
391
|
)
|
|
308
392
|
|
|
393
|
+
val seenTokens = mutableSetOf<String>()
|
|
309
394
|
for (purchase in subsResult.purchasesList) {
|
|
310
395
|
if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
|
|
396
|
+
seenTokens.add(purchase.purchaseToken)
|
|
311
397
|
val map = WritableNativeMap()
|
|
312
398
|
map.putString("transactionId", purchase.orderId ?: purchase.purchaseToken)
|
|
313
399
|
map.putString("productId", purchase.products.firstOrNull() ?: "")
|
|
@@ -317,6 +403,23 @@ class PaywalloStoreKitModule(private val reactContext: ReactApplicationContext)
|
|
|
317
403
|
}
|
|
318
404
|
}
|
|
319
405
|
|
|
406
|
+
// Refund detection: tokens we saw in the previous restore but
|
|
407
|
+
// are no longer active have been revoked. Google Play Billing
|
|
408
|
+
// has no native "refund" callback in the client SDK — this
|
|
409
|
+
// diff is the best signal available from the device.
|
|
410
|
+
val refunded = knownActiveTokens - seenTokens
|
|
411
|
+
if (refunded.isNotEmpty()) {
|
|
412
|
+
for (token in refunded) {
|
|
413
|
+
emitTransactionUpdate(
|
|
414
|
+
type = "refunded",
|
|
415
|
+
transactionId = token,
|
|
416
|
+
productId = ""
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
knownActiveTokens.clear()
|
|
421
|
+
knownActiveTokens.addAll(seenTokens)
|
|
422
|
+
|
|
320
423
|
promise.resolve(result)
|
|
321
424
|
} catch (e: Exception) {
|
|
322
425
|
promise.resolve(WritableNativeArray())
|