@mpgd/capacitor-play-billing 0.3.9 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -13
- package/android/build.gradle +42 -1
- package/android/src/main/java/dev/mpgd/capacitor/playbilling/CapacitorPlayBillingPlugin.kt +378 -0
- package/android/src/main/java/dev/mpgd/capacitor/playbilling/OpenIapPlayConnection.kt +146 -0
- package/android/src/main/java/dev/mpgd/capacitor/playbilling/PlayBillingPolicy.kt +153 -0
- package/dist/store-sdk.d.ts +3 -1
- package/dist/store-sdk.js +19 -2
- package/package.json +8 -8
- package/android/src/main/java/dev/mpgd/capacitor/playbilling/CapacitorPlayBillingPlugin.java +0 -485
package/README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# @mpgd/capacitor-play-billing
|
|
2
2
|
|
|
3
3
|
Android-only, opt-in Google Play one-time purchase collection for Capacitor 8.
|
|
4
|
-
The native module
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
The Kotlin native module runs on the [OpenIAP](https://github.com/hyodotdev/openiap)
|
|
5
|
+
Google core (`io.github.hyochan.openiap:openiap-google` 4.0.6, which brings
|
|
6
|
+
Google Play Billing Library 9.1.0). It queries product details, launches a
|
|
7
|
+
purchase, listens for purchase updates, and requeries currently owned,
|
|
8
|
+
unconsumed purchases. It intentionally does **not** acknowledge, consume, or
|
|
9
|
+
call OpenIAP's `finishTransaction`. The game-owned backend must verify the
|
|
8
10
|
purchase via Google Play Publisher, commit the mpgd ledger grant, and perform
|
|
9
11
|
acknowledge/consume after the grant. A device callback alone never grants a
|
|
10
12
|
purchase.
|
|
@@ -69,7 +71,13 @@ when `includePending` is set; each carries `token = purchaseToken` and
|
|
|
69
71
|
`transactionId = orderId`, or the `play-token-sha256:<hex>` token key until
|
|
70
72
|
Play assigns an order id, so the same purchase deduplicates in the ledger. A
|
|
71
73
|
purchased entry whose `isAcknowledged` flag is absent counts as unfinished so a
|
|
72
|
-
paid purchase is never dropped.
|
|
74
|
+
paid purchase is never dropped. The player's Restore Purchases read
|
|
75
|
+
(`restore: true`, sent only by the reconciler's `restorePurchases`) also lists
|
|
76
|
+
every `purchased` entry of exactly one configured durable, acknowledged or
|
|
77
|
+
not: after a reinstall or on a new device the backend acknowledged it long
|
|
78
|
+
ago under an earlier guest, and only this read hands it to the backend's
|
|
79
|
+
restore sharing. Acknowledged consumables, unmapped SKUs, and multi-line
|
|
80
|
+
purchases are not added. A purchase for a SKU that is not in `products`
|
|
73
81
|
(a retired product) is still listed and emitted, with that product id as
|
|
74
82
|
`sku` and the purchase token intact, so the reconciler can submit it when a
|
|
75
83
|
logical id is still known. A multi-line purchase is listed the same way,
|
|
@@ -142,8 +150,10 @@ PR C3): the service provider is registered for every catalog product whose
|
|
|
142
150
|
`platformProductIds.android` names a Play SKU (subscriptions are skipped),
|
|
143
151
|
and `accountBinding` is the reference binding in
|
|
144
152
|
`src/platform/commerceAccountBinding.ts`, the 64-character hex SHA-256 of
|
|
145
|
-
`mpgd-commerce-account:<playerId>` for the
|
|
146
|
-
|
|
153
|
+
`mpgd-commerce-account:<playerId>` for the server session's player (the
|
|
154
|
+
generated game's `guestIdentity.ts` signs in a server-issued guest when
|
|
155
|
+
`VITE_MPGD_IDENTITY_URL` is set) or an authenticated platform player; the
|
|
156
|
+
native shell's local player binds nothing, so the purchase is refused with
|
|
147
157
|
`ACCOUNT_BINDING_REQUIRED`). The backend's `resolveObfuscatedAccountId`
|
|
148
158
|
must derive the same value, or both are replaced together.
|
|
149
159
|
|
|
@@ -173,17 +183,31 @@ owned purchases over the fake plugin: a purchased token is verified once, a
|
|
|
173
183
|
pending purchase is never sent, a rejection is terminal, and an outage stays
|
|
174
184
|
retryable.
|
|
175
185
|
|
|
176
|
-
## Native
|
|
186
|
+
## Native engine
|
|
177
187
|
|
|
178
|
-
The
|
|
179
|
-
|
|
188
|
+
The plugin keeps its 0.3 Capacitor contract (methods, the `purchaseUpdated`
|
|
189
|
+
event, payload fields, `PLAY_BILLING_*` codes) over OpenIAP; the
|
|
190
|
+
[migration record](../../docs/specs/commerce/openiap-migration.md) lists the
|
|
191
|
+
contract, the call mapping, and every behavior difference. A shell needs the
|
|
192
|
+
Kotlin Gradle plugin (2.2.20 unless the root project sets `kotlin_version`;
|
|
193
|
+
OpenIAP needs 2.1.20 or newer), and the merged manifest gains OpenIAP's
|
|
194
|
+
`dev.hyo.openiap.PROVIDER` entry and `INTERNET`. The plugin refuses to run
|
|
195
|
+
unless OpenIAP's provider is `play`.
|
|
196
|
+
|
|
197
|
+
## Native build check
|
|
198
|
+
|
|
199
|
+
The Kit reference app includes an opt-in Gradle target, without bundling
|
|
200
|
+
Billing into the base app. It compiles the Kotlin plugin, runs its JVM
|
|
201
|
+
policy tests, and assembles the release AAR:
|
|
180
202
|
|
|
181
203
|
```sh
|
|
182
204
|
cd apps/mobile-capacitor/android
|
|
183
205
|
./gradlew -PmpgdPlayBillingPluginTest=true \
|
|
184
|
-
:mpgd-capacitor-play-billing:
|
|
206
|
+
:mpgd-capacitor-play-billing:testDebugUnitTest \
|
|
207
|
+
:mpgd-capacitor-play-billing:assembleRelease
|
|
185
208
|
```
|
|
186
209
|
|
|
187
|
-
|
|
188
|
-
|
|
210
|
+
`pnpm smoke:native-packed-store-plugins android` assembles the packed npm
|
|
211
|
+
copy the same way. These builds and the TypeScript fixture do not prove a
|
|
212
|
+
live Play purchase, license-test configuration, or device behavior. Validate those in a game-owned
|
|
189
213
|
Play Console project before enabling commerce for players.
|
package/android/build.gradle
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
// Same Kotlin line as the Capacitor 8 community plugins; a shell that pins
|
|
3
|
+
// `kotlin_version` in its root project wins. OpenIAP Google 4.0.6 needs a
|
|
4
|
+
// 2.1.20+ Kotlin compiler.
|
|
5
|
+
ext.kotlin_version = project.hasProperty('kotlin_version') ? rootProject.ext.kotlin_version : '2.2.20'
|
|
6
|
+
repositories {
|
|
7
|
+
google()
|
|
8
|
+
mavenCentral()
|
|
9
|
+
}
|
|
10
|
+
dependencies {
|
|
11
|
+
classpath 'com.android.tools.build:gradle:8.13.0'
|
|
12
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
1
16
|
apply plugin: 'com.android.library'
|
|
17
|
+
// AGP 9 ships built-in Kotlin; only older AGP needs the Kotlin Android plugin.
|
|
18
|
+
if (project.extensions.findByName('kotlin') == null) {
|
|
19
|
+
apply plugin: 'kotlin-android'
|
|
20
|
+
}
|
|
2
21
|
|
|
3
22
|
android {
|
|
4
23
|
namespace 'dev.mpgd.capacitor.playbilling'
|
|
@@ -8,6 +27,21 @@ android {
|
|
|
8
27
|
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
|
|
9
28
|
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
|
|
10
29
|
}
|
|
30
|
+
|
|
31
|
+
compileOptions {
|
|
32
|
+
sourceCompatibility JavaVersion.VERSION_21
|
|
33
|
+
targetCompatibility JavaVersion.VERSION_21
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
testOptions {
|
|
37
|
+
unitTests.returnDefaultValues = true
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
kotlin {
|
|
42
|
+
compilerOptions {
|
|
43
|
+
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21
|
|
44
|
+
}
|
|
11
45
|
}
|
|
12
46
|
|
|
13
47
|
repositories {
|
|
@@ -17,6 +51,13 @@ repositories {
|
|
|
17
51
|
|
|
18
52
|
dependencies {
|
|
19
53
|
implementation project(':capacitor-android')
|
|
20
|
-
|
|
54
|
+
// OpenIAP Google core, Play flavor, pinned exactly. It brings Play Billing
|
|
55
|
+
// 9.1.0 as an API dependency, so the plugin no longer declares Billing
|
|
56
|
+
// itself. See docs/specs/commerce/openiap-migration.md before changing it.
|
|
57
|
+
implementation 'io.github.hyochan.openiap:openiap-google:4.0.6'
|
|
58
|
+
// OpenIAP publishes coroutines as a runtime dependency; the plugin calls
|
|
59
|
+
// its suspend API directly, so it needs them at compile time too.
|
|
60
|
+
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0'
|
|
21
61
|
compileOnly 'androidx.appcompat:appcompat:1.7.1'
|
|
62
|
+
testImplementation 'junit:junit:4.13.2'
|
|
22
63
|
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
package dev.mpgd.capacitor.playbilling
|
|
2
|
+
|
|
3
|
+
import android.os.Handler
|
|
4
|
+
import android.os.Looper
|
|
5
|
+
import android.util.Log
|
|
6
|
+
import com.getcapacitor.JSArray
|
|
7
|
+
import com.getcapacitor.JSObject
|
|
8
|
+
import com.getcapacitor.Plugin
|
|
9
|
+
import com.getcapacitor.PluginCall
|
|
10
|
+
import com.getcapacitor.PluginMethod
|
|
11
|
+
import com.getcapacitor.annotation.CapacitorPlugin
|
|
12
|
+
import dev.hyo.openiap.OpenIapError
|
|
13
|
+
import dev.hyo.openiap.PurchaseAndroid
|
|
14
|
+
import kotlinx.coroutines.CancellationException
|
|
15
|
+
import kotlinx.coroutines.CoroutineExceptionHandler
|
|
16
|
+
import kotlinx.coroutines.CoroutineScope
|
|
17
|
+
import kotlinx.coroutines.Dispatchers
|
|
18
|
+
import kotlinx.coroutines.Job
|
|
19
|
+
import kotlinx.coroutines.SupervisorJob
|
|
20
|
+
import kotlinx.coroutines.cancel
|
|
21
|
+
import kotlinx.coroutines.delay
|
|
22
|
+
import kotlinx.coroutines.launch
|
|
23
|
+
import org.json.JSONException
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Purchase collection only, over the OpenIAP Google core. Finalization is
|
|
27
|
+
* server-owned after the mpgd ledger grant: this plugin never finishes,
|
|
28
|
+
* acknowledges, or consumes a purchase, and the encoded `isAcknowledged` flag
|
|
29
|
+
* lets the TypeScript provider list unfinished purchases for the backend
|
|
30
|
+
* finalizer. All plugin state is touched on the main thread only.
|
|
31
|
+
*/
|
|
32
|
+
@CapacitorPlugin(name = "CapacitorPlayBilling")
|
|
33
|
+
class CapacitorPlayBillingPlugin : Plugin() {
|
|
34
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
35
|
+
// Every coroutine below answers its own call; the handler only keeps a
|
|
36
|
+
// billing failure that escapes them (an Error) from crashing the game.
|
|
37
|
+
private val scope = CoroutineScope(
|
|
38
|
+
SupervisorJob() + Dispatchers.Main.immediate + CoroutineExceptionHandler { _, error ->
|
|
39
|
+
Log.e(TAG, "Unhandled Google Play Billing failure: ${error.javaClass.name}")
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
private var connection: OpenIapPlayConnection? = null
|
|
43
|
+
private var providerError: String? = null
|
|
44
|
+
private var activePurchase: PluginCall? = null
|
|
45
|
+
private var activeProductId: String? = null
|
|
46
|
+
private var purchaseTimeout: Job? = null
|
|
47
|
+
|
|
48
|
+
/** True from the start of an OpenIAP purchase flow until OpenIAP returns. */
|
|
49
|
+
private var flowInProgress = false
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Product of a purchase call that timed out while the Play UI was still
|
|
53
|
+
* up. When OpenIAP later reports that flow's cancel or failure, it is
|
|
54
|
+
* delivered as a `purchaseUpdated` event so the JavaScript attempt the
|
|
55
|
+
* timeout left open can close.
|
|
56
|
+
*/
|
|
57
|
+
private var timedOutProductId: String? = null
|
|
58
|
+
|
|
59
|
+
override fun load() {
|
|
60
|
+
try {
|
|
61
|
+
connection = OpenIapPlayConnection.open(context) { purchase ->
|
|
62
|
+
onMain { onPurchaseUpdated(purchase) }
|
|
63
|
+
}
|
|
64
|
+
} catch (error: OpenIapError) {
|
|
65
|
+
providerError = error.message
|
|
66
|
+
} catch (error: Exception) {
|
|
67
|
+
logUnexpected("provider load", error)
|
|
68
|
+
providerError = "OpenIAP could not load the Google Play provider."
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override fun handleOnDestroy() {
|
|
73
|
+
activePurchase?.let { call ->
|
|
74
|
+
clearActivePurchase(call)
|
|
75
|
+
call.reject("Purchase flow was interrupted.", "PLAY_BILLING_INTERRUPTED")
|
|
76
|
+
}
|
|
77
|
+
scope.cancel()
|
|
78
|
+
connection?.close()
|
|
79
|
+
connection = null
|
|
80
|
+
super.handleOnDestroy()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@PluginMethod
|
|
84
|
+
fun getProducts(call: PluginCall) {
|
|
85
|
+
onMain { getProductsOnMain(call) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private fun getProductsOnMain(call: PluginCall) {
|
|
89
|
+
val ids = call.getArray("productIds")
|
|
90
|
+
if (ids == null || ids.length() == 0 || ids.length() > 100) {
|
|
91
|
+
call.reject("Provide 1-100 product IDs.", "PLAY_BILLING_INVALID_PRODUCTS")
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
val productIds = mutableListOf<String>()
|
|
95
|
+
try {
|
|
96
|
+
for (index in 0 until ids.length()) {
|
|
97
|
+
val productId = ids.getString(index)
|
|
98
|
+
if (productId == null || productId.trim().isEmpty()) {
|
|
99
|
+
call.reject("Product ID is invalid.", "PLAY_BILLING_INVALID_PRODUCT")
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
productIds += productId
|
|
103
|
+
}
|
|
104
|
+
} catch (error: JSONException) {
|
|
105
|
+
call.reject("Product IDs are invalid.", "PLAY_BILLING_INVALID_PRODUCTS")
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
val active = connectionOrReject(call) ?: return
|
|
109
|
+
launchCall(call) {
|
|
110
|
+
if (!active.connect()) {
|
|
111
|
+
rejectUnavailable(call)
|
|
112
|
+
return@launchCall
|
|
113
|
+
}
|
|
114
|
+
val products = JSArray()
|
|
115
|
+
for (product in active.fetchInAppProducts(productIds)) {
|
|
116
|
+
PlayBillingPolicy.productPayload(product)?.let { products.put(toJs(it)) }
|
|
117
|
+
}
|
|
118
|
+
call.resolve(JSObject().put("products", products))
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@PluginMethod
|
|
123
|
+
fun purchase(call: PluginCall) {
|
|
124
|
+
onMain { purchaseOnMain(call) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private fun purchaseOnMain(call: PluginCall) {
|
|
128
|
+
val productId = call.getString("productId")
|
|
129
|
+
val accountId = call.getString("obfuscatedAccountId")
|
|
130
|
+
val selectedOfferToken = call.getString("offerToken")
|
|
131
|
+
if (productId == null || productId.trim().isEmpty() || accountId == null ||
|
|
132
|
+
accountId.trim().isEmpty() || accountId.length > 64
|
|
133
|
+
) {
|
|
134
|
+
call.reject("Product and account identifiers are required.", "PLAY_BILLING_INVALID_REQUEST")
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
val active = connectionOrReject(call) ?: return
|
|
138
|
+
// OpenIAP holds one flow at a time, including one whose call already
|
|
139
|
+
// timed out here; a second flow is busy until OpenIAP releases it.
|
|
140
|
+
if (activePurchase != null || flowInProgress) {
|
|
141
|
+
call.reject("Another purchase is already in progress.", "PLAY_BILLING_BUSY")
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
activePurchase = call
|
|
145
|
+
activeProductId = productId
|
|
146
|
+
timedOutProductId = null
|
|
147
|
+
flowInProgress = true
|
|
148
|
+
purchaseTimeout = scope.launch {
|
|
149
|
+
delay(PURCHASE_TIMEOUT_MILLIS)
|
|
150
|
+
if (activePurchase === call) {
|
|
151
|
+
timedOutProductId = activeProductId
|
|
152
|
+
clearActivePurchase(call)
|
|
153
|
+
call.reject("Purchase confirmation timed out; requery owned purchases.", "PLAY_BILLING_TIMEOUT")
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
scope.launch {
|
|
157
|
+
val resolution = try {
|
|
158
|
+
runPurchase(active, productId, accountId, selectedOfferToken)
|
|
159
|
+
} catch (error: CancellationException) {
|
|
160
|
+
throw error
|
|
161
|
+
} catch (error: OpenIapError) {
|
|
162
|
+
if (PlayBillingPolicy.isCancellation(error)) {
|
|
163
|
+
PurchaseResolution.Cancelled
|
|
164
|
+
} else {
|
|
165
|
+
PurchaseResolution.Failed(PlayBillingPolicy.errorCode(error), billingMessage(error))
|
|
166
|
+
}
|
|
167
|
+
} catch (error: Exception) {
|
|
168
|
+
logUnexpected("purchase", error)
|
|
169
|
+
PurchaseResolution.Failed("PLAY_BILLING_ERROR", "Google Play Billing failed.")
|
|
170
|
+
} finally {
|
|
171
|
+
// Released on every path, so one failure never leaves later
|
|
172
|
+
// purchases answering PLAY_BILLING_BUSY until a restart.
|
|
173
|
+
flowInProgress = false
|
|
174
|
+
}
|
|
175
|
+
settlePurchase(call, productId, resolution)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private suspend fun runPurchase(
|
|
180
|
+
active: OpenIapPlayConnection,
|
|
181
|
+
productId: String,
|
|
182
|
+
accountId: String,
|
|
183
|
+
selectedOfferToken: String?,
|
|
184
|
+
): PurchaseResolution {
|
|
185
|
+
if (!active.connect()) {
|
|
186
|
+
return PurchaseResolution.Failed("PLAY_BILLING_SERVICE_UNAVAILABLE", UNAVAILABLE_MESSAGE)
|
|
187
|
+
}
|
|
188
|
+
val product = active.fetchInAppProducts(listOf(productId))
|
|
189
|
+
.filter { it.id == productId && PlayBillingPolicy.isAvailable(it) }
|
|
190
|
+
.singleOrNull()
|
|
191
|
+
?: return PurchaseResolution.Failed("PLAY_BILLING_PRODUCT_UNAVAILABLE", "Product is unavailable.")
|
|
192
|
+
val offer = PlayBillingPolicy.selectOffer(PlayBillingPolicy.eligibleOffers(product), selectedOfferToken)
|
|
193
|
+
?: return PurchaseResolution.Failed("PLAY_BILLING_OFFER_REQUIRED", "Select an eligible one-time offer.")
|
|
194
|
+
val activity = activity
|
|
195
|
+
?: return PurchaseResolution.Failed("PLAY_BILLING_ACTIVITY_REQUIRED", "Billing requires a foreground activity.")
|
|
196
|
+
// Tokens Play already reports. OpenIAP answers ITEM_ALREADY_OWNED with
|
|
197
|
+
// the owned purchase; the 0.3 contract rejects instead, and the owned
|
|
198
|
+
// purchase still reaches JavaScript through `purchaseUpdated`.
|
|
199
|
+
val ownedBefore = active.inAppPurchases().mapNotNullTo(HashSet()) { it.purchaseToken }
|
|
200
|
+
val (purchases, error) = active.purchase(activity, productId, offer.offerTokenAndroid.orEmpty(), accountId)
|
|
201
|
+
val matched = purchases.firstOrNull { it.ids.orEmpty().contains(productId) }
|
|
202
|
+
if (matched != null) {
|
|
203
|
+
if (matched.purchaseToken in ownedBefore) {
|
|
204
|
+
return PurchaseResolution.Failed("PLAY_BILLING_ALREADY_OWNED", "The product is already owned.")
|
|
205
|
+
}
|
|
206
|
+
val payload = PlayBillingPolicy.purchasePayload(matched)
|
|
207
|
+
?: return PurchaseResolution.Failed("PLAY_BILLING_EMPTY_PURCHASE", EMPTY_MESSAGE)
|
|
208
|
+
return PurchaseResolution.Reported(PlayBillingPolicy.outcomePayload(payload))
|
|
209
|
+
}
|
|
210
|
+
return when {
|
|
211
|
+
error == null -> PurchaseResolution.Failed("PLAY_BILLING_EMPTY_PURCHASE", EMPTY_MESSAGE)
|
|
212
|
+
PlayBillingPolicy.isCancellation(error) -> PurchaseResolution.Cancelled
|
|
213
|
+
else -> PurchaseResolution.Failed(PlayBillingPolicy.errorCode(error), billingMessage(error))
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private fun settlePurchase(call: PluginCall, productId: String, resolution: PurchaseResolution) {
|
|
218
|
+
if (activePurchase === call) {
|
|
219
|
+
clearActivePurchase(call)
|
|
220
|
+
when (resolution) {
|
|
221
|
+
is PurchaseResolution.Reported -> call.resolve(toJs(resolution.outcome))
|
|
222
|
+
PurchaseResolution.Cancelled -> call.resolve(JSObject().put("status", "cancelled"))
|
|
223
|
+
is PurchaseResolution.Failed -> call.reject(resolution.message, resolution.code)
|
|
224
|
+
}
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
// The call already timed out. A purchase reached JavaScript through
|
|
228
|
+
// `purchaseUpdated`; a cancel or failure closes the open attempt.
|
|
229
|
+
if (timedOutProductId != productId) return
|
|
230
|
+
when (resolution) {
|
|
231
|
+
is PurchaseResolution.Reported -> timedOutProductId = null
|
|
232
|
+
PurchaseResolution.Cancelled -> notifyLateTerminalResult("cancelled", null)
|
|
233
|
+
is PurchaseResolution.Failed ->
|
|
234
|
+
if (resolution.code != "PLAY_BILLING_EMPTY_PURCHASE") {
|
|
235
|
+
notifyLateTerminalResult("failed", resolution.code)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
@PluginMethod
|
|
241
|
+
fun getPurchases(call: PluginCall) {
|
|
242
|
+
onMain {
|
|
243
|
+
val active = connectionOrReject(call) ?: return@onMain
|
|
244
|
+
launchCall(call) {
|
|
245
|
+
if (!active.connect()) {
|
|
246
|
+
rejectUnavailable(call)
|
|
247
|
+
return@launchCall
|
|
248
|
+
}
|
|
249
|
+
val purchases = JSArray()
|
|
250
|
+
for (purchase in active.inAppPurchases()) {
|
|
251
|
+
PlayBillingPolicy.purchasePayload(purchase)?.let { purchases.put(toJs(it)) }
|
|
252
|
+
}
|
|
253
|
+
call.resolve(JSObject().put("purchases", purchases))
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private fun onPurchaseUpdated(purchase: PurchaseAndroid) {
|
|
259
|
+
val payload = PlayBillingPolicy.purchasePayload(purchase) ?: return
|
|
260
|
+
// Retained until a listener consumes it: a purchase that completes
|
|
261
|
+
// after the purchase call timed out, or before the web view attached
|
|
262
|
+
// its listener, must still reach the provider's subscription.
|
|
263
|
+
notifyListeners("purchaseUpdated", toJs(PlayBillingPolicy.outcomePayload(payload)), true)
|
|
264
|
+
val timedOut = timedOutProductId
|
|
265
|
+
if (timedOut != null && purchase.ids.orEmpty().contains(timedOut)) {
|
|
266
|
+
timedOutProductId = null
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* A cancel or failure that arrives after the purchase call already timed
|
|
272
|
+
* out has no call to answer; without this event the JavaScript attempt
|
|
273
|
+
* would stay open and block another purchase of the product.
|
|
274
|
+
*/
|
|
275
|
+
private fun notifyLateTerminalResult(status: String, code: String?) {
|
|
276
|
+
val productId = timedOutProductId ?: return
|
|
277
|
+
timedOutProductId = null
|
|
278
|
+
val outcome = JSObject().put("status", status).put("productId", productId)
|
|
279
|
+
if (code != null) {
|
|
280
|
+
outcome.put("code", code)
|
|
281
|
+
}
|
|
282
|
+
notifyListeners("purchaseUpdated", outcome, true)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private fun connectionOrReject(call: PluginCall): OpenIapPlayConnection? {
|
|
286
|
+
connection?.let { return it }
|
|
287
|
+
call.reject(providerError ?: "Google Play Billing is not configured.", "PLAY_BILLING_CONFIGURATION_ERROR")
|
|
288
|
+
return null
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Runs a query on the main thread and answers every outcome, interruption included. */
|
|
292
|
+
private fun launchCall(call: PluginCall, block: suspend () -> Unit) {
|
|
293
|
+
scope.launch {
|
|
294
|
+
try {
|
|
295
|
+
block()
|
|
296
|
+
} catch (error: CancellationException) {
|
|
297
|
+
call.reject("Billing connection was interrupted.", "PLAY_BILLING_INTERRUPTED")
|
|
298
|
+
throw error
|
|
299
|
+
} catch (error: OpenIapError) {
|
|
300
|
+
call.reject(billingMessage(error), PlayBillingPolicy.errorCode(error))
|
|
301
|
+
} catch (error: Exception) {
|
|
302
|
+
logUnexpected(call.methodName ?: "query", error)
|
|
303
|
+
call.reject("Google Play Billing failed.", "PLAY_BILLING_ERROR")
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The exception class only: a message or stack trace can carry a
|
|
310
|
+
* purchase token or account id, so neither is logged.
|
|
311
|
+
*/
|
|
312
|
+
private fun logUnexpected(operation: String, error: Exception) {
|
|
313
|
+
Log.w(TAG, "Unexpected Google Play Billing failure in $operation: ${error.javaClass.name}")
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private fun rejectUnavailable(call: PluginCall) {
|
|
317
|
+
call.reject(UNAVAILABLE_MESSAGE, "PLAY_BILLING_SERVICE_UNAVAILABLE")
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private fun clearActivePurchase(call: PluginCall) {
|
|
321
|
+
if (activePurchase === call) {
|
|
322
|
+
activePurchase = null
|
|
323
|
+
activeProductId = null
|
|
324
|
+
purchaseTimeout?.cancel()
|
|
325
|
+
purchaseTimeout = null
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private fun onMain(task: () -> Unit) {
|
|
330
|
+
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
331
|
+
task()
|
|
332
|
+
} else {
|
|
333
|
+
mainHandler.post(task)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private sealed class PurchaseResolution {
|
|
338
|
+
data class Reported(val outcome: Map<String, Any>) : PurchaseResolution()
|
|
339
|
+
object Cancelled : PurchaseResolution()
|
|
340
|
+
data class Failed(val code: String, val message: String) : PurchaseResolution()
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private companion object {
|
|
344
|
+
const val TAG = "CapacitorPlayBilling"
|
|
345
|
+
const val PURCHASE_TIMEOUT_MILLIS = 180_000L
|
|
346
|
+
const val UNAVAILABLE_MESSAGE = "Google Play Billing is unavailable."
|
|
347
|
+
const val EMPTY_MESSAGE = "Purchase callback was empty; requery owned purchases."
|
|
348
|
+
|
|
349
|
+
fun billingMessage(error: OpenIapError): String =
|
|
350
|
+
"Google Play Billing is unavailable (${error.code})."
|
|
351
|
+
|
|
352
|
+
@Suppress("UNCHECKED_CAST")
|
|
353
|
+
fun toJs(value: Map<String, Any>): JSObject {
|
|
354
|
+
val result = JSObject()
|
|
355
|
+
for ((key, item) in value) {
|
|
356
|
+
when (item) {
|
|
357
|
+
is Map<*, *> -> result.put(key, toJs(item as Map<String, Any>))
|
|
358
|
+
is List<*> -> result.put(key, toJsArray(item))
|
|
359
|
+
else -> result.put(key, item)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return result
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
@Suppress("UNCHECKED_CAST")
|
|
366
|
+
fun toJsArray(values: List<*>): JSArray {
|
|
367
|
+
val result = JSArray()
|
|
368
|
+
for (item in values) {
|
|
369
|
+
when (item) {
|
|
370
|
+
is Map<*, *> -> result.put(toJs(item as Map<String, Any>))
|
|
371
|
+
is List<*> -> result.put(toJsArray(item))
|
|
372
|
+
else -> result.put(item)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return result
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
package dev.mpgd.capacitor.playbilling
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import dev.hyo.openiap.FetchProductsResultProducts
|
|
6
|
+
import dev.hyo.openiap.OpenIapError
|
|
7
|
+
import dev.hyo.openiap.OpenIapProtocol
|
|
8
|
+
import dev.hyo.openiap.OpenIapProvider
|
|
9
|
+
import dev.hyo.openiap.ProductAndroid
|
|
10
|
+
import dev.hyo.openiap.ProductQueryType
|
|
11
|
+
import dev.hyo.openiap.ProductRequest
|
|
12
|
+
import dev.hyo.openiap.PurchaseAndroid
|
|
13
|
+
import dev.hyo.openiap.RequestPurchaseAndroidProps
|
|
14
|
+
import dev.hyo.openiap.RequestPurchaseProps
|
|
15
|
+
import dev.hyo.openiap.RequestPurchasePropsByPlatforms
|
|
16
|
+
import dev.hyo.openiap.RequestPurchaseResultPurchase
|
|
17
|
+
import dev.hyo.openiap.RequestPurchaseResultPurchases
|
|
18
|
+
import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener
|
|
19
|
+
import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener
|
|
20
|
+
import dev.hyo.openiap.store.OpenIapStore
|
|
21
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
22
|
+
import kotlinx.coroutines.CoroutineScope
|
|
23
|
+
import kotlinx.coroutines.Dispatchers
|
|
24
|
+
import kotlinx.coroutines.SupervisorJob
|
|
25
|
+
import kotlinx.coroutines.launch
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The OpenIAP Google connection one Capacitor plugin instance owns: the
|
|
29
|
+
* store-neutral half of the bridge.
|
|
30
|
+
*
|
|
31
|
+
* The provider comes from OpenIAP's own discovery (the merged
|
|
32
|
+
* `dev.hyo.openiap.PROVIDER` manifest entry), not from a hard-coded Play
|
|
33
|
+
* class, and the plugin refuses to run unless that provider is `play`: a
|
|
34
|
+
* receipt from another store must never reach the Google Play verifier.
|
|
35
|
+
* `OpenIapStore` is the public facade for connection, products, and purchase
|
|
36
|
+
* flows; the provider module is kept only for the in-app-only ownership
|
|
37
|
+
* query (`getAvailableItems(InApp)`), which the facade does not expose.
|
|
38
|
+
*
|
|
39
|
+
* The connection never finishes a purchase. `OpenIapStore.finishTransaction`,
|
|
40
|
+
* `acknowledgePurchaseAndroid`, and `consumePurchaseAndroid` are not called
|
|
41
|
+
* anywhere in this plugin: the mpgd backend finalizer consumes or
|
|
42
|
+
* acknowledges after its ledger grant.
|
|
43
|
+
*/
|
|
44
|
+
internal class OpenIapPlayConnection private constructor(
|
|
45
|
+
private val module: OpenIapProtocol,
|
|
46
|
+
private val store: OpenIapStore,
|
|
47
|
+
onPurchase: (PurchaseAndroid) -> Unit,
|
|
48
|
+
) {
|
|
49
|
+
private val errorSink = AtomicReference<((OpenIapError) -> Unit)?>(null)
|
|
50
|
+
private val updateListener = OpenIapPurchaseUpdateListener { purchase ->
|
|
51
|
+
(purchase as? PurchaseAndroid)?.let(onPurchase)
|
|
52
|
+
}
|
|
53
|
+
private val errorListener = OpenIapPurchaseErrorListener { error ->
|
|
54
|
+
errorSink.get()?.invoke(error)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
init {
|
|
58
|
+
store.addPurchaseUpdateListener(updateListener)
|
|
59
|
+
store.addPurchaseErrorListener(errorListener)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Connects, or confirms the live connection. False when Play Billing is unavailable. */
|
|
63
|
+
suspend fun connect(): Boolean = store.initConnection(null)
|
|
64
|
+
|
|
65
|
+
suspend fun fetchInAppProducts(productIds: List<String>): List<ProductAndroid> {
|
|
66
|
+
val result = store.fetchProducts(ProductRequest(skus = productIds, type = ProductQueryType.InApp))
|
|
67
|
+
return (result as? FetchProductsResultProducts)?.value.orEmpty().filterIsInstance<ProductAndroid>()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Every in-app purchase Play reports (`queryPurchasesAsync(INAPP)`), never subscriptions. */
|
|
71
|
+
suspend fun inAppPurchases(): List<PurchaseAndroid> =
|
|
72
|
+
module.getAvailableItems(ProductQueryType.InApp).filterIsInstance<PurchaseAndroid>()
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Runs one purchase flow and returns the purchases OpenIAP matched to it,
|
|
76
|
+
* with the error OpenIAP published for it when it matched none. OpenIAP
|
|
77
|
+
* reports most flow failures (cancel included) through its error listener
|
|
78
|
+
* and an empty result rather than by throwing.
|
|
79
|
+
*/
|
|
80
|
+
suspend fun purchase(
|
|
81
|
+
activity: Activity,
|
|
82
|
+
productId: String,
|
|
83
|
+
offerToken: String,
|
|
84
|
+
obfuscatedAccountId: String,
|
|
85
|
+
): Pair<List<PurchaseAndroid>, OpenIapError?> {
|
|
86
|
+
// OpenIAP publishes one error per purchase request, tagged with its
|
|
87
|
+
// product; an untagged error counts only until a tagged one arrives.
|
|
88
|
+
val captured = AtomicReference<OpenIapError?>(null)
|
|
89
|
+
errorSink.set { error ->
|
|
90
|
+
when (error.requestProductId) {
|
|
91
|
+
productId -> captured.set(error)
|
|
92
|
+
null -> captured.compareAndSet(null, error)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
store.setActivity(activity)
|
|
97
|
+
val result = store.requestPurchase(
|
|
98
|
+
RequestPurchaseProps(
|
|
99
|
+
request = RequestPurchaseProps.Request.Purchase(
|
|
100
|
+
RequestPurchasePropsByPlatforms(
|
|
101
|
+
google = RequestPurchaseAndroidProps(
|
|
102
|
+
obfuscatedAccountId = obfuscatedAccountId,
|
|
103
|
+
offerToken = offerToken,
|
|
104
|
+
skus = listOf(productId),
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
type = ProductQueryType.InApp,
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
val purchases = when (result) {
|
|
112
|
+
is RequestPurchaseResultPurchases -> result.value.orEmpty()
|
|
113
|
+
is RequestPurchaseResultPurchase -> listOfNotNull(result.value)
|
|
114
|
+
else -> emptyList()
|
|
115
|
+
}.filterIsInstance<PurchaseAndroid>()
|
|
116
|
+
return purchases to captured.get()
|
|
117
|
+
} finally {
|
|
118
|
+
errorSink.set(null)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fun close() {
|
|
123
|
+
store.removePurchaseUpdateListener(updateListener)
|
|
124
|
+
store.removePurchaseErrorListener(errorListener)
|
|
125
|
+
errorSink.set(null)
|
|
126
|
+
store.clear()
|
|
127
|
+
// Ending the connection never touches a purchase; it only releases Play.
|
|
128
|
+
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
|
129
|
+
runCatching { store.endConnection() }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
companion object {
|
|
134
|
+
/** Throws `OpenIapError.ProviderConfiguration` unless OpenIAP links the Play provider. */
|
|
135
|
+
fun open(context: Context, onPurchase: (PurchaseAndroid) -> Unit): OpenIapPlayConnection {
|
|
136
|
+
val factory = OpenIapProvider.factory(context)
|
|
137
|
+
if (factory.storeId != PlayBillingPolicy.PLAY_STORE_ID) {
|
|
138
|
+
throw OpenIapError.ProviderConfiguration(
|
|
139
|
+
"OpenIAP links the ${factory.storeId} provider; this plugin requires play.",
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
val module = OpenIapProvider.create(context, factory)
|
|
143
|
+
return OpenIapPlayConnection(module, OpenIapStore(module), onPurchase)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|