@virex-tech/paywallo-sdk 1.5.3 → 2.0.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.
@@ -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"
@@ -7,7 +7,7 @@ buildscript {
7
7
  mavenCentral()
8
8
  }
9
9
  dependencies {
10
- classpath("com.android.tools.build:gradle:7.4.2")
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())