@capivv/capacitor-sdk 0.1.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/CapivvCapacitor.podspec +20 -0
- package/README.md +378 -0
- package/android/build.gradle +74 -0
- package/android/src/main/AndroidManifest.xml +5 -0
- package/android/src/main/java/com/capivv/capacitor/CapivvPlugin.kt +708 -0
- package/dist/esm/components/index.d.ts +1 -0
- package/dist/esm/components/index.js +9 -0
- package/dist/esm/components/index.js.map +1 -0
- package/dist/esm/definitions.d.ts +327 -0
- package/dist/esm/definitions.js +20 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +7 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/l10n/translations.d.ts +73 -0
- package/dist/esm/l10n/translations.js +397 -0
- package/dist/esm/l10n/translations.js.map +1 -0
- package/dist/esm/templates/types.d.ts +118 -0
- package/dist/esm/templates/types.js +8 -0
- package/dist/esm/templates/types.js.map +1 -0
- package/dist/esm/web.d.ts +72 -0
- package/dist/esm/web.js +230 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +661 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +664 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Plugin/CapivvPlugin.m +21 -0
- package/ios/Plugin/CapivvPlugin.swift +655 -0
- package/package.json +89 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
package com.capivv.capacitor
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.net.Uri
|
|
6
|
+
import com.android.billingclient.api.*
|
|
7
|
+
import com.getcapacitor.*
|
|
8
|
+
import com.getcapacitor.annotation.CapacitorPlugin
|
|
9
|
+
import com.getcapacitor.annotation.PluginMethod
|
|
10
|
+
import kotlinx.coroutines.*
|
|
11
|
+
import okhttp3.*
|
|
12
|
+
import okhttp3.MediaType.Companion.toMediaType
|
|
13
|
+
import okhttp3.RequestBody.Companion.toRequestBody
|
|
14
|
+
import org.json.JSONArray
|
|
15
|
+
import org.json.JSONObject
|
|
16
|
+
import java.io.IOException
|
|
17
|
+
import kotlin.coroutines.resume
|
|
18
|
+
import kotlin.coroutines.resumeWithException
|
|
19
|
+
import kotlin.coroutines.suspendCoroutine
|
|
20
|
+
|
|
21
|
+
@CapacitorPlugin(name = "Capivv")
|
|
22
|
+
class CapivvPlugin : Plugin() {
|
|
23
|
+
|
|
24
|
+
private var apiKey: String? = null
|
|
25
|
+
private var apiUrl: String = "https://api.capivv.com"
|
|
26
|
+
private var userId: String? = null
|
|
27
|
+
private var debug: Boolean = false
|
|
28
|
+
|
|
29
|
+
private var billingClient: BillingClient? = null
|
|
30
|
+
private val httpClient = OkHttpClient()
|
|
31
|
+
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
|
32
|
+
|
|
33
|
+
private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
|
|
34
|
+
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
|
|
35
|
+
for (purchase in purchases) {
|
|
36
|
+
handlePurchase(purchase)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
override fun load() {
|
|
42
|
+
super.load()
|
|
43
|
+
initBillingClient()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private fun initBillingClient() {
|
|
47
|
+
billingClient = BillingClient.newBuilder(context)
|
|
48
|
+
.setListener(purchasesUpdatedListener)
|
|
49
|
+
.enablePendingPurchases()
|
|
50
|
+
.build()
|
|
51
|
+
|
|
52
|
+
billingClient?.startConnection(object : BillingClientStateListener {
|
|
53
|
+
override fun onBillingSetupFinished(billingResult: BillingResult) {
|
|
54
|
+
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
|
55
|
+
log("Billing client connected")
|
|
56
|
+
} else {
|
|
57
|
+
log("Billing client setup failed: ${billingResult.debugMessage}")
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
override fun onBillingServiceDisconnected() {
|
|
62
|
+
log("Billing client disconnected")
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private fun handlePurchase(purchase: Purchase) {
|
|
68
|
+
if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
|
|
69
|
+
scope.launch {
|
|
70
|
+
try {
|
|
71
|
+
userId?.let { uid ->
|
|
72
|
+
verifyPurchase(purchase, uid)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Acknowledge if not already
|
|
76
|
+
if (!purchase.isAcknowledged) {
|
|
77
|
+
val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
|
|
78
|
+
.setPurchaseToken(purchase.purchaseToken)
|
|
79
|
+
.build()
|
|
80
|
+
|
|
81
|
+
billingClient?.acknowledgePurchase(acknowledgePurchaseParams) { result ->
|
|
82
|
+
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
|
83
|
+
log("Purchase acknowledged")
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
val transactionData = mapPurchaseToTransaction(purchase)
|
|
89
|
+
notifyListeners("purchaseCompleted", JSObject().apply {
|
|
90
|
+
put("transaction", transactionData)
|
|
91
|
+
})
|
|
92
|
+
} catch (e: Exception) {
|
|
93
|
+
log("Error handling purchase: ${e.message}")
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// MARK: - Configuration
|
|
100
|
+
|
|
101
|
+
@PluginMethod
|
|
102
|
+
fun configure(call: PluginCall) {
|
|
103
|
+
val key = call.getString("apiKey")
|
|
104
|
+
if (key == null) {
|
|
105
|
+
call.reject("apiKey is required")
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
apiKey = key
|
|
110
|
+
call.getString("apiUrl")?.let { apiUrl = it }
|
|
111
|
+
debug = call.getBoolean("debug") ?: false
|
|
112
|
+
|
|
113
|
+
log("Configured with API URL: $apiUrl")
|
|
114
|
+
call.resolve()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// MARK: - User Management
|
|
118
|
+
|
|
119
|
+
@PluginMethod
|
|
120
|
+
fun identify(call: PluginCall) {
|
|
121
|
+
if (apiKey == null) {
|
|
122
|
+
call.reject("Not configured. Call configure() first.")
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
val uid = call.getString("userId")
|
|
127
|
+
if (uid == null) {
|
|
128
|
+
call.reject("userId is required")
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
userId = uid
|
|
133
|
+
val attributes = call.getObject("attributes") ?: JSObject()
|
|
134
|
+
|
|
135
|
+
scope.launch {
|
|
136
|
+
try {
|
|
137
|
+
val body = JSONObject().apply {
|
|
138
|
+
put("attributes", attributes)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
val response = apiRequest("POST", "/v1/users/$uid/login", body)
|
|
142
|
+
|
|
143
|
+
call.resolve(JSObject().apply {
|
|
144
|
+
put("userId", uid)
|
|
145
|
+
put("entitlements", response.optJSONArray("entitlements") ?: JSONArray())
|
|
146
|
+
put("originalPurchaseDate", response.optString("original_purchase_date", null))
|
|
147
|
+
put("latestPurchaseDate", response.optString("latest_purchase_date", null))
|
|
148
|
+
})
|
|
149
|
+
} catch (e: Exception) {
|
|
150
|
+
call.reject("Failed to identify user: ${e.message}")
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@PluginMethod
|
|
156
|
+
fun logout(call: PluginCall) {
|
|
157
|
+
userId = null
|
|
158
|
+
call.resolve()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@PluginMethod
|
|
162
|
+
fun getUserInfo(call: PluginCall) {
|
|
163
|
+
val uid = userId
|
|
164
|
+
if (uid == null) {
|
|
165
|
+
call.reject("Not identified. Call identify() first.")
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
scope.launch {
|
|
170
|
+
try {
|
|
171
|
+
val response = apiRequest("GET", "/v1/users/$uid/entitlements")
|
|
172
|
+
|
|
173
|
+
call.resolve(JSObject().apply {
|
|
174
|
+
put("userId", uid)
|
|
175
|
+
put("entitlements", response.optJSONArray("entitlements") ?: JSONArray())
|
|
176
|
+
})
|
|
177
|
+
} catch (e: Exception) {
|
|
178
|
+
call.reject("Failed to get user info: ${e.message}")
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// MARK: - Billing
|
|
184
|
+
|
|
185
|
+
@PluginMethod
|
|
186
|
+
fun isBillingSupported(call: PluginCall) {
|
|
187
|
+
val isReady = billingClient?.isReady ?: false
|
|
188
|
+
call.resolve(JSObject().apply {
|
|
189
|
+
put("isSupported", isReady)
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// MARK: - Products & Offerings
|
|
194
|
+
|
|
195
|
+
@PluginMethod
|
|
196
|
+
fun getOfferings(call: PluginCall) {
|
|
197
|
+
if (apiKey == null) {
|
|
198
|
+
call.reject("Not configured. Call configure() first.")
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
scope.launch {
|
|
203
|
+
try {
|
|
204
|
+
val response = apiRequest("GET", "/v1/offerings")
|
|
205
|
+
val offerings = response.optJSONArray("offerings") ?: JSONArray()
|
|
206
|
+
|
|
207
|
+
val enrichedOfferings = JSONArray()
|
|
208
|
+
|
|
209
|
+
for (i in 0 until offerings.length()) {
|
|
210
|
+
val offering = offerings.getJSONObject(i)
|
|
211
|
+
val products = offering.optJSONArray("products") ?: JSONArray()
|
|
212
|
+
|
|
213
|
+
val productIds = mutableListOf<String>()
|
|
214
|
+
for (j in 0 until products.length()) {
|
|
215
|
+
val product = products.getJSONObject(j)
|
|
216
|
+
product.optString("store_product_id")?.let { productIds.add(it) }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
val enrichedProducts = queryProducts(productIds)
|
|
220
|
+
|
|
221
|
+
val enrichedOffering = JSONObject(offering.toString())
|
|
222
|
+
enrichedOffering.put("products", enrichedProducts)
|
|
223
|
+
enrichedOfferings.put(enrichedOffering)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
call.resolve(JSObject().apply {
|
|
227
|
+
put("offerings", enrichedOfferings)
|
|
228
|
+
})
|
|
229
|
+
} catch (e: Exception) {
|
|
230
|
+
call.reject("Failed to get offerings: ${e.message}")
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
@PluginMethod
|
|
236
|
+
fun getProduct(call: PluginCall) {
|
|
237
|
+
val productIdentifier = call.getString("productIdentifier")
|
|
238
|
+
if (productIdentifier == null) {
|
|
239
|
+
call.reject("productIdentifier is required")
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
scope.launch {
|
|
244
|
+
try {
|
|
245
|
+
val products = queryProducts(listOf(productIdentifier))
|
|
246
|
+
|
|
247
|
+
if (products.length() == 0) {
|
|
248
|
+
call.reject("Product not found: $productIdentifier")
|
|
249
|
+
return@launch
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
call.resolve(JSObject().apply {
|
|
253
|
+
put("product", products.getJSONObject(0))
|
|
254
|
+
})
|
|
255
|
+
} catch (e: Exception) {
|
|
256
|
+
call.reject("Failed to get product: ${e.message}")
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
@PluginMethod
|
|
262
|
+
fun getProducts(call: PluginCall) {
|
|
263
|
+
val productIdentifiers = call.getArray("productIdentifiers")?.toList<String>()
|
|
264
|
+
if (productIdentifiers == null) {
|
|
265
|
+
call.reject("productIdentifiers is required")
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
scope.launch {
|
|
270
|
+
try {
|
|
271
|
+
val products = queryProducts(productIdentifiers)
|
|
272
|
+
|
|
273
|
+
call.resolve(JSObject().apply {
|
|
274
|
+
put("products", products)
|
|
275
|
+
})
|
|
276
|
+
} catch (e: Exception) {
|
|
277
|
+
call.reject("Failed to get products: ${e.message}")
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private suspend fun queryProducts(productIds: List<String>): JSONArray = suspendCoroutine { cont ->
|
|
283
|
+
val client = billingClient
|
|
284
|
+
if (client == null || !client.isReady) {
|
|
285
|
+
cont.resume(JSONArray())
|
|
286
|
+
return@suspendCoroutine
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
val productList = productIds.map { productId ->
|
|
290
|
+
QueryProductDetailsParams.Product.newBuilder()
|
|
291
|
+
.setProductId(productId)
|
|
292
|
+
.setProductType(BillingClient.ProductType.SUBS)
|
|
293
|
+
.build()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
val params = QueryProductDetailsParams.newBuilder()
|
|
297
|
+
.setProductList(productList)
|
|
298
|
+
.build()
|
|
299
|
+
|
|
300
|
+
client.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
|
|
301
|
+
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
|
302
|
+
val products = JSONArray()
|
|
303
|
+
for (details in productDetailsList) {
|
|
304
|
+
products.put(mapProductDetails(details))
|
|
305
|
+
}
|
|
306
|
+
cont.resume(products)
|
|
307
|
+
} else {
|
|
308
|
+
cont.resume(JSONArray())
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// MARK: - Purchases
|
|
314
|
+
|
|
315
|
+
@PluginMethod
|
|
316
|
+
fun purchase(call: PluginCall) {
|
|
317
|
+
if (apiKey == null) {
|
|
318
|
+
call.reject("Not configured. Call configure() first.")
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (userId == null) {
|
|
323
|
+
call.reject("Not identified. Call identify() first.")
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
val productIdentifier = call.getString("productIdentifier")
|
|
328
|
+
if (productIdentifier == null) {
|
|
329
|
+
call.reject("productIdentifier is required")
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
val client = billingClient
|
|
334
|
+
if (client == null || !client.isReady) {
|
|
335
|
+
call.resolve(JSObject().apply {
|
|
336
|
+
put("success", false)
|
|
337
|
+
put("error", "Billing client not ready")
|
|
338
|
+
})
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
scope.launch {
|
|
343
|
+
try {
|
|
344
|
+
val productList = listOf(
|
|
345
|
+
QueryProductDetailsParams.Product.newBuilder()
|
|
346
|
+
.setProductId(productIdentifier)
|
|
347
|
+
.setProductType(BillingClient.ProductType.SUBS)
|
|
348
|
+
.build()
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
val params = QueryProductDetailsParams.newBuilder()
|
|
352
|
+
.setProductList(productList)
|
|
353
|
+
.build()
|
|
354
|
+
|
|
355
|
+
client.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
|
|
356
|
+
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK || productDetailsList.isEmpty()) {
|
|
357
|
+
call.resolve(JSObject().apply {
|
|
358
|
+
put("success", false)
|
|
359
|
+
put("error", "Product not found: $productIdentifier")
|
|
360
|
+
})
|
|
361
|
+
return@queryProductDetailsAsync
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
val productDetails = productDetailsList[0]
|
|
365
|
+
|
|
366
|
+
// Get the offer token for subscriptions
|
|
367
|
+
val offerToken = productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken
|
|
368
|
+
|
|
369
|
+
val productDetailsParamsList = listOf(
|
|
370
|
+
BillingFlowParams.ProductDetailsParams.newBuilder()
|
|
371
|
+
.setProductDetails(productDetails)
|
|
372
|
+
.apply {
|
|
373
|
+
offerToken?.let { setOfferToken(it) }
|
|
374
|
+
}
|
|
375
|
+
.build()
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
val billingFlowParams = BillingFlowParams.newBuilder()
|
|
379
|
+
.setProductDetailsParamsList(productDetailsParamsList)
|
|
380
|
+
.build()
|
|
381
|
+
|
|
382
|
+
val launchResult = client.launchBillingFlow(activity, billingFlowParams)
|
|
383
|
+
|
|
384
|
+
if (launchResult.responseCode != BillingClient.BillingResponseCode.OK) {
|
|
385
|
+
call.resolve(JSObject().apply {
|
|
386
|
+
put("success", false)
|
|
387
|
+
put("error", "Failed to launch billing flow: ${launchResult.debugMessage}")
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
// Purchase result will be handled by purchasesUpdatedListener
|
|
391
|
+
// We don't resolve here - the listener will handle it
|
|
392
|
+
}
|
|
393
|
+
} catch (e: Exception) {
|
|
394
|
+
call.resolve(JSObject().apply {
|
|
395
|
+
put("success", false)
|
|
396
|
+
put("error", e.message)
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
@PluginMethod
|
|
403
|
+
fun restorePurchases(call: PluginCall) {
|
|
404
|
+
val uid = userId
|
|
405
|
+
if (uid == null) {
|
|
406
|
+
call.reject("Not identified. Call identify() first.")
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
val client = billingClient
|
|
411
|
+
if (client == null || !client.isReady) {
|
|
412
|
+
call.reject("Billing client not ready")
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
scope.launch {
|
|
417
|
+
try {
|
|
418
|
+
val params = QueryPurchasesParams.newBuilder()
|
|
419
|
+
.setProductType(BillingClient.ProductType.SUBS)
|
|
420
|
+
.build()
|
|
421
|
+
|
|
422
|
+
val purchasesResult = client.queryPurchasesAsync(params)
|
|
423
|
+
|
|
424
|
+
if (purchasesResult.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
|
425
|
+
for (purchase in purchasesResult.purchasesList) {
|
|
426
|
+
if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
|
|
427
|
+
verifyPurchase(purchase, uid)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Fetch updated entitlements
|
|
433
|
+
val response = apiRequest("GET", "/v1/users/$uid/entitlements")
|
|
434
|
+
val entitlements = response.optJSONArray("entitlements") ?: JSONArray()
|
|
435
|
+
|
|
436
|
+
notifyListeners("entitlementsUpdated", JSObject().apply {
|
|
437
|
+
put("entitlements", entitlements)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
call.resolve(JSObject().apply {
|
|
441
|
+
put("entitlements", entitlements)
|
|
442
|
+
})
|
|
443
|
+
} catch (e: Exception) {
|
|
444
|
+
call.reject("Failed to restore purchases: ${e.message}")
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// MARK: - Entitlements
|
|
450
|
+
|
|
451
|
+
@PluginMethod
|
|
452
|
+
fun checkEntitlement(call: PluginCall) {
|
|
453
|
+
val uid = userId
|
|
454
|
+
if (uid == null) {
|
|
455
|
+
call.reject("Not identified. Call identify() first.")
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
val entitlementIdentifier = call.getString("entitlementIdentifier")
|
|
460
|
+
if (entitlementIdentifier == null) {
|
|
461
|
+
call.reject("entitlementIdentifier is required")
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
scope.launch {
|
|
466
|
+
try {
|
|
467
|
+
val response = apiRequest("GET", "/v1/users/$uid/entitlements")
|
|
468
|
+
val entitlements = response.optJSONArray("entitlements") ?: JSONArray()
|
|
469
|
+
|
|
470
|
+
for (i in 0 until entitlements.length()) {
|
|
471
|
+
val entitlement = entitlements.getJSONObject(i)
|
|
472
|
+
if (entitlement.optString("identifier") == entitlementIdentifier) {
|
|
473
|
+
val isActive = entitlement.optBoolean("is_active", false)
|
|
474
|
+
call.resolve(JSObject().apply {
|
|
475
|
+
put("hasAccess", isActive)
|
|
476
|
+
put("entitlement", entitlement.toString())
|
|
477
|
+
})
|
|
478
|
+
return@launch
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
call.resolve(JSObject().apply {
|
|
483
|
+
put("hasAccess", false)
|
|
484
|
+
})
|
|
485
|
+
} catch (e: Exception) {
|
|
486
|
+
call.reject("Failed to check entitlement: ${e.message}")
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
@PluginMethod
|
|
492
|
+
fun getEntitlements(call: PluginCall) {
|
|
493
|
+
val uid = userId
|
|
494
|
+
if (uid == null) {
|
|
495
|
+
call.reject("Not identified. Call identify() first.")
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
scope.launch {
|
|
500
|
+
try {
|
|
501
|
+
val response = apiRequest("GET", "/v1/users/$uid/entitlements")
|
|
502
|
+
|
|
503
|
+
call.resolve(JSObject().apply {
|
|
504
|
+
put("entitlements", response.optJSONArray("entitlements") ?: JSONArray())
|
|
505
|
+
})
|
|
506
|
+
} catch (e: Exception) {
|
|
507
|
+
call.reject("Failed to get entitlements: ${e.message}")
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
@PluginMethod
|
|
513
|
+
fun syncPurchases(call: PluginCall) {
|
|
514
|
+
val uid = userId
|
|
515
|
+
if (uid == null) {
|
|
516
|
+
call.reject("Not identified. Call identify() first.")
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
val client = billingClient
|
|
521
|
+
if (client == null || !client.isReady) {
|
|
522
|
+
// If billing client is not ready, just fetch from server
|
|
523
|
+
scope.launch {
|
|
524
|
+
try {
|
|
525
|
+
val response = apiRequest("GET", "/v1/users/$uid/entitlements")
|
|
526
|
+
|
|
527
|
+
call.resolve(JSObject().apply {
|
|
528
|
+
put("entitlements", response.optJSONArray("entitlements") ?: JSONArray())
|
|
529
|
+
})
|
|
530
|
+
} catch (e: Exception) {
|
|
531
|
+
call.reject("Failed to sync purchases: ${e.message}")
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
scope.launch {
|
|
538
|
+
try {
|
|
539
|
+
val params = QueryPurchasesParams.newBuilder()
|
|
540
|
+
.setProductType(BillingClient.ProductType.SUBS)
|
|
541
|
+
.build()
|
|
542
|
+
|
|
543
|
+
val purchasesResult = client.queryPurchasesAsync(params)
|
|
544
|
+
|
|
545
|
+
if (purchasesResult.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
|
|
546
|
+
for (purchase in purchasesResult.purchasesList) {
|
|
547
|
+
if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
|
|
548
|
+
verifyPurchase(purchase, uid)
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Fetch updated entitlements
|
|
554
|
+
val response = apiRequest("GET", "/v1/users/$uid/entitlements")
|
|
555
|
+
val entitlements = response.optJSONArray("entitlements") ?: JSONArray()
|
|
556
|
+
|
|
557
|
+
notifyListeners("entitlementsUpdated", JSObject().apply {
|
|
558
|
+
put("entitlements", entitlements)
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
call.resolve(JSObject().apply {
|
|
562
|
+
put("entitlements", entitlements)
|
|
563
|
+
})
|
|
564
|
+
} catch (e: Exception) {
|
|
565
|
+
call.reject("Failed to sync purchases: ${e.message}")
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
@PluginMethod
|
|
571
|
+
fun manageSubscriptions(call: PluginCall) {
|
|
572
|
+
try {
|
|
573
|
+
val intent = Intent(Intent.ACTION_VIEW).apply {
|
|
574
|
+
data = Uri.parse("https://play.google.com/store/account/subscriptions")
|
|
575
|
+
}
|
|
576
|
+
activity.startActivity(intent)
|
|
577
|
+
call.resolve()
|
|
578
|
+
} catch (e: Exception) {
|
|
579
|
+
call.reject("Failed to open subscription management: ${e.message}")
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// MARK: - Private Helpers
|
|
584
|
+
|
|
585
|
+
private suspend fun verifyPurchase(purchase: Purchase, userId: String) {
|
|
586
|
+
try {
|
|
587
|
+
val body = JSONObject().apply {
|
|
588
|
+
put("user_id", userId)
|
|
589
|
+
put("purchase_token", purchase.purchaseToken)
|
|
590
|
+
put("product_ids", JSONArray(purchase.products))
|
|
591
|
+
put("order_id", purchase.orderId)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
apiRequest("POST", "/v1/purchases/google/verify", body)
|
|
595
|
+
log("Purchase verified: ${purchase.orderId}")
|
|
596
|
+
} catch (e: Exception) {
|
|
597
|
+
log("Failed to verify purchase: ${e.message}")
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private fun mapProductDetails(details: ProductDetails): JSONObject {
|
|
602
|
+
val result = JSONObject().apply {
|
|
603
|
+
put("identifier", details.productId)
|
|
604
|
+
put("title", details.title)
|
|
605
|
+
put("description", details.description)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
details.subscriptionOfferDetails?.firstOrNull()?.let { offer ->
|
|
609
|
+
val pricingPhase = offer.pricingPhases.pricingPhaseList.firstOrNull()
|
|
610
|
+
pricingPhase?.let { phase ->
|
|
611
|
+
result.put("priceString", phase.formattedPrice)
|
|
612
|
+
result.put("priceAmountMicros", phase.priceAmountMicros)
|
|
613
|
+
result.put("currencyCode", phase.priceCurrencyCode)
|
|
614
|
+
result.put("productType", "SUBSCRIPTION")
|
|
615
|
+
result.put("subscriptionPeriod", phase.billingPeriod)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Check for free trial
|
|
619
|
+
offer.pricingPhases.pricingPhaseList.find { it.priceAmountMicros == 0L }?.let { trial ->
|
|
620
|
+
result.put("trialPeriod", trial.billingPeriod)
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
details.oneTimePurchaseOfferDetails?.let { offer ->
|
|
625
|
+
result.put("priceString", offer.formattedPrice)
|
|
626
|
+
result.put("priceAmountMicros", offer.priceAmountMicros)
|
|
627
|
+
result.put("currencyCode", offer.priceCurrencyCode)
|
|
628
|
+
result.put("productType", "INAPP")
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return result
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private fun mapPurchaseToTransaction(purchase: Purchase): JSObject {
|
|
635
|
+
return JSObject().apply {
|
|
636
|
+
put("transactionId", purchase.orderId ?: "")
|
|
637
|
+
put("productIdentifier", purchase.products.firstOrNull() ?: "")
|
|
638
|
+
put("purchaseDate", java.time.Instant.ofEpochMilli(purchase.purchaseTime).toString())
|
|
639
|
+
put("isAcknowledged", purchase.isAcknowledged)
|
|
640
|
+
put("state", when (purchase.purchaseState) {
|
|
641
|
+
Purchase.PurchaseState.PURCHASED -> "PURCHASED"
|
|
642
|
+
Purchase.PurchaseState.PENDING -> "PENDING"
|
|
643
|
+
else -> "FAILED"
|
|
644
|
+
})
|
|
645
|
+
put("purchaseToken", purchase.purchaseToken)
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private suspend fun apiRequest(method: String, path: String, body: JSONObject? = null): JSONObject =
|
|
650
|
+
suspendCoroutine { cont ->
|
|
651
|
+
val key = apiKey
|
|
652
|
+
if (key == null) {
|
|
653
|
+
cont.resumeWithException(Exception("Not configured"))
|
|
654
|
+
return@suspendCoroutine
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
val url = "$apiUrl$path"
|
|
658
|
+
|
|
659
|
+
val requestBuilder = Request.Builder()
|
|
660
|
+
.url(url)
|
|
661
|
+
.addHeader("Content-Type", "application/json")
|
|
662
|
+
.addHeader("X-Capivv-Api-Key", key)
|
|
663
|
+
|
|
664
|
+
when (method) {
|
|
665
|
+
"GET" -> requestBuilder.get()
|
|
666
|
+
"POST" -> requestBuilder.post(
|
|
667
|
+
(body?.toString() ?: "{}").toRequestBody("application/json".toMediaType())
|
|
668
|
+
)
|
|
669
|
+
"PUT" -> requestBuilder.put(
|
|
670
|
+
(body?.toString() ?: "{}").toRequestBody("application/json".toMediaType())
|
|
671
|
+
)
|
|
672
|
+
"DELETE" -> requestBuilder.delete()
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
httpClient.newCall(requestBuilder.build()).enqueue(object : Callback {
|
|
676
|
+
override fun onFailure(call: Call, e: IOException) {
|
|
677
|
+
cont.resumeWithException(e)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
override fun onResponse(call: Call, response: Response) {
|
|
681
|
+
val responseBody = response.body?.string() ?: "{}"
|
|
682
|
+
|
|
683
|
+
if (!response.isSuccessful) {
|
|
684
|
+
cont.resumeWithException(Exception("API error (${response.code}): $responseBody"))
|
|
685
|
+
return
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
try {
|
|
689
|
+
cont.resume(JSONObject(responseBody))
|
|
690
|
+
} catch (e: Exception) {
|
|
691
|
+
cont.resume(JSONObject())
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
})
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private fun log(message: String) {
|
|
698
|
+
if (debug) {
|
|
699
|
+
android.util.Log.d("Capivv", message)
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
override fun handleOnDestroy() {
|
|
704
|
+
super.handleOnDestroy()
|
|
705
|
+
billingClient?.endConnection()
|
|
706
|
+
scope.cancel()
|
|
707
|
+
}
|
|
708
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|