@mpgd/capacitor-play-billing 0.5.0 → 0.6.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 +65 -0
- package/android/src/main/java/dev/mpgd/capacitor/playbilling/CapacitorPlayBillingPlugin.kt +220 -24
- package/android/src/main/java/dev/mpgd/capacitor/playbilling/PurchasePresentationTracker.kt +210 -0
- package/dist/commerce-provider.d.ts +9 -0
- package/dist/commerce-provider.js +3 -0
- package/dist/definitions.d.ts +37 -0
- package/dist/purchase-presentation.d.ts +27 -0
- package/dist/purchase-presentation.js +9 -0
- package/dist/store-sdk.d.ts +20 -0
- package/dist/store-sdk.js +38 -0
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -150,6 +150,71 @@ discarded. The plugin still never acknowledges or consumes;
|
|
|
150
150
|
`isAcknowledged: false` on a purchased entry marks it unfinished for the
|
|
151
151
|
backend finalizer.
|
|
152
152
|
|
|
153
|
+
### Purchase sheet facts
|
|
154
|
+
|
|
155
|
+
The provider offers `CommerceProvider.presentation` (see "Store sheet facts"
|
|
156
|
+
in the `@mpgd/adapter-capacitor` README), so the gateway keeps the game
|
|
157
|
+
paused, and the game runtime keeps ads out, until the Play sheet is gone
|
|
158
|
+
rather than until a purchase result. The native plugin observes the sheet
|
|
159
|
+
from the activity lifecycle, never from `onPurchasesUpdated`:
|
|
160
|
+
|
|
161
|
+
- `open` when a Billing Library activity
|
|
162
|
+
(`com.android.billingclient.api.ProxyBillingActivity` or `...V2`, the
|
|
163
|
+
translucent activity `launchBillingFlow` starts in the app process once it
|
|
164
|
+
returns OK) is created during the flow, however late;
|
|
165
|
+
- `closed` when an app activity that is not a billing activity resumes after
|
|
166
|
+
it (the game's activity sits below the sheet in the same task), with
|
|
167
|
+
`reason: 'interrupted'` when the app is torn down (finishing), or with
|
|
168
|
+
`reason: 'timeout'` after 15 minutes without the game's activity resuming;
|
|
169
|
+
- `not-started` when the flow ended without a billing activity: a refusal
|
|
170
|
+
before the launch, or a launch that brought nothing over the game. After a
|
|
171
|
+
launch attempt the plugin waits 500 ms for the activity, and while the
|
|
172
|
+
game's activity is paused (something is coming over it) the verdict waits
|
|
173
|
+
until it resumes (`not-started`), a billing activity appears (`open`), or
|
|
174
|
+
10 seconds pass.
|
|
175
|
+
|
|
176
|
+
The plugin answers `PLAY_BILLING_BUSY` to a new purchase until the previous
|
|
177
|
+
flow's terminal fact. A pending purchase, a purchase made outside the app,
|
|
178
|
+
and the 180-second call timeout are not sheet facts. When the call times out
|
|
179
|
+
before the flow ever reached `launchBillingFlow` (stuck before the launch),
|
|
180
|
+
the plugin ends that flow `not-started` and cancels it; a flow that launched
|
|
181
|
+
answers `presentation: 'requested'` when nothing was observed yet, and the
|
|
182
|
+
provider then reports the sheet `unknown` and waits for the native verdict
|
|
183
|
+
instead of assuming a sheet. Facts reach JavaScript as `purchasePresentation`
|
|
184
|
+
events (retained until a listener attaches, keyed by a random id per call)
|
|
185
|
+
and in the `presentation` field of the `purchase` answer or rejection data.
|
|
186
|
+
The adapter adds a 15-minute safety timeout.
|
|
187
|
+
|
|
188
|
+
The host activity must handle configuration changes itself, as Capacitor's
|
|
189
|
+
`BridgeActivity` does by default (`android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"`):
|
|
190
|
+
an activity recreated while the Play sheet is up loses the web view and the
|
|
191
|
+
plugin's observations. The plugin then claims no `closed` for a sheet that
|
|
192
|
+
may still be up (it reports `interrupted` only when the activity is
|
|
193
|
+
finishing), and the reference shell's manifest check
|
|
194
|
+
(`pnpm validate:capacitor-alignment`) keeps the attribute.
|
|
195
|
+
|
|
196
|
+
`getPresentationSupport` declares the facts. A web bundle running on a native
|
|
197
|
+
build from before them (a live update) gets `UNIMPLEMENTED` there: while an
|
|
198
|
+
adapter follows the facts, the store then reads `configuration-required` (the
|
|
199
|
+
shop sells nothing and the reconciler spends no idempotency key), and a
|
|
200
|
+
purchase that still gets through is refused with `CONFIGURATION_REQUIRED` /
|
|
201
|
+
`PLAY_BILLING_PRESENTATION_UNSUPPORTED` instead of opening a sheet the game
|
|
202
|
+
would wait on; recovery is unaffected. `purchasePresentation: false` on the
|
|
203
|
+
provider keeps the result-based pause of earlier releases. Google documents
|
|
204
|
+
that `launchBillingFlow` returning `OK` "indicates a successful launch"
|
|
205
|
+
([Integrate the Google Play Billing Library](https://developer.android.com/google/play/billing/integrate#launch));
|
|
206
|
+
the proxy activity names are the Billing Library 9.1.0 implementation (its
|
|
207
|
+
manifest declares both, in the app process), not a documented contract, so a
|
|
208
|
+
Billing Library or OpenIAP upgrade must re-check them.
|
|
209
|
+
|
|
210
|
+
Device acceptance (not run; no device was available): the sheet cancelled by
|
|
211
|
+
its close button and by the back button, a completed purchase, a pending
|
|
212
|
+
(slow card) purchase, the app backgrounded with the sheet up and resumed, and
|
|
213
|
+
a rotation with the sheet up. Each must pause the game from `open` to
|
|
214
|
+
`closed` and never resume it under the sheet. The steps and expected signals
|
|
215
|
+
are in the kit's
|
|
216
|
+
[purchase device acceptance checklist](https://github.com/imjlk/mpgd-kit/blob/main/docs/PURCHASE_DEVICE_ACCEPTANCE.md).
|
|
217
|
+
|
|
153
218
|
### Generated games
|
|
154
219
|
|
|
155
220
|
Games generated by `mpgd game create` (and the kit's private starter) ship
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
package dev.mpgd.capacitor.playbilling
|
|
2
2
|
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.app.Application
|
|
5
|
+
import android.os.Bundle
|
|
3
6
|
import android.os.Handler
|
|
4
7
|
import android.os.Looper
|
|
5
8
|
import android.util.Log
|
|
@@ -18,7 +21,9 @@ import kotlinx.coroutines.Dispatchers
|
|
|
18
21
|
import kotlinx.coroutines.Job
|
|
19
22
|
import kotlinx.coroutines.SupervisorJob
|
|
20
23
|
import kotlinx.coroutines.cancel
|
|
24
|
+
import kotlinx.coroutines.currentCoroutineContext
|
|
21
25
|
import kotlinx.coroutines.delay
|
|
26
|
+
import kotlinx.coroutines.ensureActive
|
|
22
27
|
import kotlinx.coroutines.launch
|
|
23
28
|
import org.json.JSONException
|
|
24
29
|
|
|
@@ -56,6 +61,43 @@ class CapacitorPlayBillingPlugin : Plugin() {
|
|
|
56
61
|
*/
|
|
57
62
|
private var timedOutProductId: String? = null
|
|
58
63
|
|
|
64
|
+
/** Sheet facts of the flow in progress; see `PurchasePresentationTracker`. */
|
|
65
|
+
private val presentation = PurchasePresentationTracker { id, state, reason ->
|
|
66
|
+
if (state == PurchasePresentationTracker.OPEN) {
|
|
67
|
+
armSheetTimeout(id)
|
|
68
|
+
} else {
|
|
69
|
+
sheetTimeout?.cancel()
|
|
70
|
+
sheetTimeout = null
|
|
71
|
+
}
|
|
72
|
+
notifyPresentation(id, state, reason)
|
|
73
|
+
}
|
|
74
|
+
private var sheetTimeout: Job? = null
|
|
75
|
+
private var purchaseJob: Job? = null
|
|
76
|
+
private var lateLaunchBound: Job? = null
|
|
77
|
+
private var presentationId: String? = null
|
|
78
|
+
private var lifecycleApplication: Application? = null
|
|
79
|
+
|
|
80
|
+
// Every activity of the app process, the Billing Library's proxy
|
|
81
|
+
// activities included; callbacks arrive on the main thread.
|
|
82
|
+
private val activityCallbacks = object : Application.ActivityLifecycleCallbacks {
|
|
83
|
+
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
|
|
84
|
+
presentation.onActivityCreated(activity.javaClass.name)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
override fun onActivityResumed(activity: Activity) {
|
|
88
|
+
presentation.onActivityResumed(activity.javaClass.name)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
override fun onActivityPaused(activity: Activity) {
|
|
92
|
+
presentation.onActivityPaused(activity.javaClass.name)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
override fun onActivityStarted(activity: Activity) = Unit
|
|
96
|
+
override fun onActivityStopped(activity: Activity) = Unit
|
|
97
|
+
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
|
98
|
+
override fun onActivityDestroyed(activity: Activity) = Unit
|
|
99
|
+
}
|
|
100
|
+
|
|
59
101
|
override fun load() {
|
|
60
102
|
try {
|
|
61
103
|
connection = OpenIapPlayConnection.open(context) { purchase ->
|
|
@@ -67,19 +109,42 @@ class CapacitorPlayBillingPlugin : Plugin() {
|
|
|
67
109
|
logUnexpected("provider load", error)
|
|
68
110
|
providerError = "OpenIAP could not load the Google Play provider."
|
|
69
111
|
}
|
|
112
|
+
lifecycleApplication = (context.applicationContext as? Application)?.also {
|
|
113
|
+
it.registerActivityLifecycleCallbacks(activityCallbacks)
|
|
114
|
+
}
|
|
70
115
|
}
|
|
71
116
|
|
|
72
117
|
override fun handleOnDestroy() {
|
|
118
|
+
// Nothing is observed after this. A finishing app reports a sheet
|
|
119
|
+
// still open as closed (`interrupted`); an activity that is only
|
|
120
|
+
// recreated (a configuration change the manifest does not handle)
|
|
121
|
+
// may leave the Play sheet up, so nothing is claimed for it.
|
|
122
|
+
presentation.onTeardown(reportClosed = activity?.isChangingConfigurations != true)
|
|
123
|
+
lateLaunchBound?.cancel()
|
|
73
124
|
activePurchase?.let { call ->
|
|
125
|
+
val id = presentationId
|
|
74
126
|
clearActivePurchase(call)
|
|
75
|
-
call.reject("Purchase flow was interrupted.", "PLAY_BILLING_INTERRUPTED")
|
|
127
|
+
call.reject("Purchase flow was interrupted.", "PLAY_BILLING_INTERRUPTED", null, presentationData(id))
|
|
76
128
|
}
|
|
129
|
+
lifecycleApplication?.unregisterActivityLifecycleCallbacks(activityCallbacks)
|
|
130
|
+
lifecycleApplication = null
|
|
77
131
|
scope.cancel()
|
|
78
132
|
connection?.close()
|
|
79
133
|
connection = null
|
|
80
134
|
super.handleOnDestroy()
|
|
81
135
|
}
|
|
82
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Declares the sheet facts this native code reports (`purchasePresentation`
|
|
139
|
+
* events and the `presentation` field of `purchase` answers). A plugin
|
|
140
|
+
* build without this method answers `UNIMPLEMENTED`, and the TypeScript
|
|
141
|
+
* SDK then sells nothing that would wait for facts.
|
|
142
|
+
*/
|
|
143
|
+
@PluginMethod
|
|
144
|
+
fun getPresentationSupport(call: PluginCall) {
|
|
145
|
+
call.resolve(JSObject().put("purchasePresentation", PurchasePresentationTracker.PROTOCOL_VERSION))
|
|
146
|
+
}
|
|
147
|
+
|
|
83
148
|
@PluginMethod
|
|
84
149
|
fun getProducts(call: PluginCall) {
|
|
85
150
|
onMain { getProductsOnMain(call) }
|
|
@@ -128,51 +193,111 @@ class CapacitorPlayBillingPlugin : Plugin() {
|
|
|
128
193
|
val productId = call.getString("productId")
|
|
129
194
|
val accountId = call.getString("obfuscatedAccountId")
|
|
130
195
|
val selectedOfferToken = call.getString("offerToken")
|
|
196
|
+
// Correlates this call's sheet facts; sent only by a TypeScript SDK
|
|
197
|
+
// that reads them. Without it the flow reports no facts, as before.
|
|
198
|
+
val requestedPresentationId = call.getString("presentationId")
|
|
199
|
+
if (requestedPresentationId != null &&
|
|
200
|
+
!PurchasePresentationTracker.isValidPresentationId(requestedPresentationId)
|
|
201
|
+
) {
|
|
202
|
+
call.reject(
|
|
203
|
+
"The presentation id is invalid.",
|
|
204
|
+
"PLAY_BILLING_INVALID_REQUEST",
|
|
205
|
+
null,
|
|
206
|
+
JSObject().put("presentation", PurchasePresentationTracker.NOT_STARTED),
|
|
207
|
+
)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
131
210
|
if (productId == null || productId.trim().isEmpty() || accountId == null ||
|
|
132
211
|
accountId.trim().isEmpty() || accountId.length > 64
|
|
133
212
|
) {
|
|
134
|
-
call
|
|
213
|
+
rejectNotStarted(call, requestedPresentationId, "Product and account identifiers are required.", "PLAY_BILLING_INVALID_REQUEST")
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
val active = connection
|
|
217
|
+
if (active == null) {
|
|
218
|
+
rejectNotStarted(
|
|
219
|
+
call,
|
|
220
|
+
requestedPresentationId,
|
|
221
|
+
providerError ?: "Google Play Billing is not configured.",
|
|
222
|
+
"PLAY_BILLING_CONFIGURATION_ERROR",
|
|
223
|
+
)
|
|
135
224
|
return
|
|
136
225
|
}
|
|
137
|
-
val active = connectionOrReject(call) ?: return
|
|
138
226
|
// 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
|
-
|
|
141
|
-
|
|
227
|
+
// timed out here; a second flow is busy until OpenIAP releases it, and
|
|
228
|
+
// until the sheet of the last one is gone (its `closed` is still owed).
|
|
229
|
+
if (activePurchase != null || flowInProgress || presentation.active) {
|
|
230
|
+
rejectNotStarted(call, requestedPresentationId, "Another purchase is already in progress.", "PLAY_BILLING_BUSY")
|
|
142
231
|
return
|
|
143
232
|
}
|
|
144
233
|
activePurchase = call
|
|
145
234
|
activeProductId = productId
|
|
235
|
+
presentationId = requestedPresentationId
|
|
146
236
|
timedOutProductId = null
|
|
147
237
|
flowInProgress = true
|
|
238
|
+
requestedPresentationId?.let { presentation.begin(it) }
|
|
148
239
|
purchaseTimeout = scope.launch {
|
|
149
240
|
delay(PURCHASE_TIMEOUT_MILLIS)
|
|
150
241
|
if (activePurchase === call) {
|
|
242
|
+
val id = presentationId
|
|
243
|
+
// A flow that never reached `launchBillingFlow` (stuck before
|
|
244
|
+
// the launch) is ended here, `not-started`, and cancelled, so
|
|
245
|
+
// the game is not held for a sheet that cannot come. A
|
|
246
|
+
// launched flow keeps running and reports its sheet as events.
|
|
247
|
+
if (id != null && presentation.cancelBeforeLaunch(id)) {
|
|
248
|
+
purchaseJob?.cancel()
|
|
249
|
+
}
|
|
151
250
|
timedOutProductId = activeProductId
|
|
152
251
|
clearActivePurchase(call)
|
|
153
|
-
|
|
252
|
+
// The sheet may still be up: its facts keep arriving as events.
|
|
253
|
+
call.reject(
|
|
254
|
+
"Purchase confirmation timed out; requery owned purchases.",
|
|
255
|
+
"PLAY_BILLING_TIMEOUT",
|
|
256
|
+
null,
|
|
257
|
+
presentationData(id),
|
|
258
|
+
)
|
|
154
259
|
}
|
|
155
260
|
}
|
|
156
|
-
scope.launch {
|
|
261
|
+
purchaseJob = scope.launch {
|
|
157
262
|
val resolution = try {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
263
|
+
val outcome = try {
|
|
264
|
+
runPurchase(active, productId, accountId, selectedOfferToken, requestedPresentationId)
|
|
265
|
+
} catch (error: CancellationException) {
|
|
266
|
+
throw error
|
|
267
|
+
} catch (error: OpenIapError) {
|
|
268
|
+
if (PlayBillingPolicy.isCancellation(error)) {
|
|
269
|
+
PurchaseResolution.Cancelled
|
|
270
|
+
} else {
|
|
271
|
+
PurchaseResolution.Failed(PlayBillingPolicy.errorCode(error), billingMessage(error))
|
|
272
|
+
}
|
|
273
|
+
} catch (error: Exception) {
|
|
274
|
+
logUnexpected("purchase", error)
|
|
275
|
+
PurchaseResolution.Failed("PLAY_BILLING_ERROR", "Google Play Billing failed.")
|
|
166
276
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
277
|
+
// `launchBillingFlow` was called but no billing activity was
|
|
278
|
+
// seen yet: give one that was just started time to be created
|
|
279
|
+
// before the flow counts as never shown.
|
|
280
|
+
if (requestedPresentationId != null &&
|
|
281
|
+
presentation.awaitsLaunchObservation(requestedPresentationId)
|
|
282
|
+
) {
|
|
283
|
+
delay(LAUNCH_OBSERVATION_GRACE_MILLIS)
|
|
284
|
+
}
|
|
285
|
+
outcome
|
|
170
286
|
} finally {
|
|
287
|
+
requestedPresentationId?.let { id ->
|
|
288
|
+
presentation.onFlowEnded(id)
|
|
289
|
+
// The game's activity is still paused after a launch
|
|
290
|
+
// attempt: the verdict waits for it (or a late billing
|
|
291
|
+
// activity), bounded here. The flow stays busy meanwhile.
|
|
292
|
+
if (presentation.isTracking(id) && presentation.openPresentationId == null) {
|
|
293
|
+
armLateLaunchBound(id)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
171
296
|
// Released on every path, so one failure never leaves later
|
|
172
297
|
// purchases answering PLAY_BILLING_BUSY until a restart.
|
|
173
298
|
flowInProgress = false
|
|
174
299
|
}
|
|
175
|
-
settlePurchase(call, productId, resolution)
|
|
300
|
+
settlePurchase(call, productId, requestedPresentationId, resolution)
|
|
176
301
|
}
|
|
177
302
|
}
|
|
178
303
|
|
|
@@ -181,6 +306,7 @@ class CapacitorPlayBillingPlugin : Plugin() {
|
|
|
181
306
|
productId: String,
|
|
182
307
|
accountId: String,
|
|
183
308
|
selectedOfferToken: String?,
|
|
309
|
+
presentationId: String?,
|
|
184
310
|
): PurchaseResolution {
|
|
185
311
|
if (!active.connect()) {
|
|
186
312
|
return PurchaseResolution.Failed("PLAY_BILLING_SERVICE_UNAVAILABLE", UNAVAILABLE_MESSAGE)
|
|
@@ -197,6 +323,12 @@ class CapacitorPlayBillingPlugin : Plugin() {
|
|
|
197
323
|
// the owned purchase; the 0.3 contract rejects instead, and the owned
|
|
198
324
|
// purchase still reaches JavaScript through `purchaseUpdated`.
|
|
199
325
|
val ownedBefore = active.inAppPurchases().mapNotNullTo(HashSet()) { it.purchaseToken }
|
|
326
|
+
// A flow the call timeout already ended must not open a sheet now.
|
|
327
|
+
currentCoroutineContext().ensureActive()
|
|
328
|
+
if (presentationId != null && !presentation.isTracking(presentationId)) {
|
|
329
|
+
return PurchaseResolution.Failed("PLAY_BILLING_TIMEOUT", "Purchase confirmation timed out; requery owned purchases.")
|
|
330
|
+
}
|
|
331
|
+
presentationId?.let { presentation.onLaunchAttempted(it) }
|
|
200
332
|
val (purchases, error) = active.purchase(activity, productId, offer.offerTokenAndroid.orEmpty(), accountId)
|
|
201
333
|
val matched = purchases.firstOrNull { it.ids.orEmpty().contains(productId) }
|
|
202
334
|
if (matched != null) {
|
|
@@ -214,13 +346,22 @@ class CapacitorPlayBillingPlugin : Plugin() {
|
|
|
214
346
|
}
|
|
215
347
|
}
|
|
216
348
|
|
|
217
|
-
private fun settlePurchase(
|
|
349
|
+
private fun settlePurchase(
|
|
350
|
+
call: PluginCall,
|
|
351
|
+
productId: String,
|
|
352
|
+
presentationId: String?,
|
|
353
|
+
resolution: PurchaseResolution,
|
|
354
|
+
) {
|
|
218
355
|
if (activePurchase === call) {
|
|
219
356
|
clearActivePurchase(call)
|
|
357
|
+
// The answer carries the sheet state too, so the SDK reads a
|
|
358
|
+
// `not-started` or `closed` that the event may not have delivered
|
|
359
|
+
// yet. `open` means the sheet is still up and `closed` follows.
|
|
360
|
+
val sheet = presentationId?.let { presentation.answerState(it) }
|
|
220
361
|
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)
|
|
362
|
+
is PurchaseResolution.Reported -> call.resolve(withPresentation(toJs(resolution.outcome), sheet))
|
|
363
|
+
PurchaseResolution.Cancelled -> call.resolve(withPresentation(JSObject().put("status", "cancelled"), sheet))
|
|
364
|
+
is PurchaseResolution.Failed -> call.reject(resolution.message, resolution.code, null, presentationData(presentationId))
|
|
224
365
|
}
|
|
225
366
|
return
|
|
226
367
|
}
|
|
@@ -282,6 +423,54 @@ class CapacitorPlayBillingPlugin : Plugin() {
|
|
|
282
423
|
notifyListeners("purchaseUpdated", outcome, true)
|
|
283
424
|
}
|
|
284
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Bounds how long one sheet keeps the plugin busy without the host
|
|
428
|
+
* resuming; the TypeScript provider has the same bound for the game.
|
|
429
|
+
*/
|
|
430
|
+
private fun armSheetTimeout(id: String) {
|
|
431
|
+
sheetTimeout?.cancel()
|
|
432
|
+
sheetTimeout = scope.launch {
|
|
433
|
+
delay(SHEET_TIMEOUT_MILLIS)
|
|
434
|
+
presentation.onSafetyTimeout(id)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private fun armLateLaunchBound(id: String) {
|
|
439
|
+
lateLaunchBound?.cancel()
|
|
440
|
+
lateLaunchBound = scope.launch {
|
|
441
|
+
delay(LATE_LAUNCH_BOUND_MILLIS)
|
|
442
|
+
presentation.onLateBound(id)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** A refusal before any flow began; its sheet never opened. */
|
|
447
|
+
private fun rejectNotStarted(call: PluginCall, id: String?, message: String, code: String) {
|
|
448
|
+
if (id == null) {
|
|
449
|
+
call.reject(message, code)
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
notifyPresentation(id, PurchasePresentationTracker.NOT_STARTED, null)
|
|
453
|
+
call.reject(message, code, null, JSObject().put("presentation", PurchasePresentationTracker.NOT_STARTED))
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private fun presentationData(id: String?): JSObject? =
|
|
457
|
+
id?.let { JSObject().put("presentation", presentation.answerState(it)) }
|
|
458
|
+
|
|
459
|
+
private fun withPresentation(answer: JSObject, sheet: String?): JSObject =
|
|
460
|
+
if (sheet == null) answer else answer.put("presentation", sheet)
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Retained until a listener consumes it, like `purchaseUpdated`; the
|
|
464
|
+
* random id keeps a fact of an earlier web session from matching a new call.
|
|
465
|
+
*/
|
|
466
|
+
private fun notifyPresentation(id: String, state: String, reason: String?) {
|
|
467
|
+
val event = JSObject().put("presentationId", id).put("state", state)
|
|
468
|
+
if (reason != null) {
|
|
469
|
+
event.put("reason", reason)
|
|
470
|
+
}
|
|
471
|
+
notifyListeners("purchasePresentation", event, true)
|
|
472
|
+
}
|
|
473
|
+
|
|
285
474
|
private fun connectionOrReject(call: PluginCall): OpenIapPlayConnection? {
|
|
286
475
|
connection?.let { return it }
|
|
287
476
|
call.reject(providerError ?: "Google Play Billing is not configured.", "PLAY_BILLING_CONFIGURATION_ERROR")
|
|
@@ -321,6 +510,7 @@ class CapacitorPlayBillingPlugin : Plugin() {
|
|
|
321
510
|
if (activePurchase === call) {
|
|
322
511
|
activePurchase = null
|
|
323
512
|
activeProductId = null
|
|
513
|
+
presentationId = null
|
|
324
514
|
purchaseTimeout?.cancel()
|
|
325
515
|
purchaseTimeout = null
|
|
326
516
|
}
|
|
@@ -343,6 +533,12 @@ class CapacitorPlayBillingPlugin : Plugin() {
|
|
|
343
533
|
private companion object {
|
|
344
534
|
const val TAG = "CapacitorPlayBilling"
|
|
345
535
|
const val PURCHASE_TIMEOUT_MILLIS = 180_000L
|
|
536
|
+
/** How long a launched flow waits for its billing activity before it counts as never shown. */
|
|
537
|
+
const val LAUNCH_OBSERVATION_GRACE_MILLIS = 500L
|
|
538
|
+
/** How long a launched flow's verdict may wait for the game's paused activity. */
|
|
539
|
+
const val LATE_LAUNCH_BOUND_MILLIS = 10_000L
|
|
540
|
+
/** The OpenIAP Google purchase flow timeout, and the TypeScript provider's sheet bound. */
|
|
541
|
+
const val SHEET_TIMEOUT_MILLIS = 900_000L
|
|
346
542
|
const val UNAVAILABLE_MESSAGE = "Google Play Billing is unavailable."
|
|
347
543
|
const val EMPTY_MESSAGE = "Purchase callback was empty; requery owned purchases."
|
|
348
544
|
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
package dev.mpgd.capacitor.playbilling
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sheet facts for one Play purchase flow at a time, from activity lifecycle
|
|
5
|
+
* observations only, never from a purchase result:
|
|
6
|
+
*
|
|
7
|
+
* - `open` when a Billing Library activity
|
|
8
|
+
* (`com.android.billingclient.api.ProxyBillingActivity` or `...V2`, the
|
|
9
|
+
* translucent activity `launchBillingFlow` starts in this process once it
|
|
10
|
+
* returns OK) is created during the flow, however late;
|
|
11
|
+
* - `closed` when an activity of the app that is not a billing activity
|
|
12
|
+
* resumes after that: the host sits below the billing activities in the
|
|
13
|
+
* same task, so it resumes only once the Play sheet is gone;
|
|
14
|
+
* - `not-started` when the flow ended without a billing activity: a refusal
|
|
15
|
+
* before the launch, or a launch that brought nothing over the game. After
|
|
16
|
+
* a launch attempt the verdict waits while the game's activity is paused
|
|
17
|
+
* (something is coming over it, a billing activity that is slow to be
|
|
18
|
+
* created included) until it resumes, a billing activity appears, or the
|
|
19
|
+
* plugin's bound passes.
|
|
20
|
+
*
|
|
21
|
+
* `onPurchasesUpdated`, a pending purchase, or the plugin's call timeout are
|
|
22
|
+
* not observations of the sheet. Every `open` is followed by exactly one
|
|
23
|
+
* `closed`: the activity that resumes, teardown (`interrupted`), or the
|
|
24
|
+
* plugin's safety bound (`timeout`). The flow stays [active] (the plugin
|
|
25
|
+
* answers `PLAY_BILLING_BUSY`) until its terminal fact. Pure Kotlin, driven
|
|
26
|
+
* on the main thread by the plugin, so the JVM tests can run it.
|
|
27
|
+
*/
|
|
28
|
+
internal class PurchasePresentationTracker(
|
|
29
|
+
private val emit: (presentationId: String, state: String, reason: String?) -> Unit,
|
|
30
|
+
) {
|
|
31
|
+
private enum class State { REQUESTED, OPEN }
|
|
32
|
+
|
|
33
|
+
private var presentationId: String? = null
|
|
34
|
+
private var state = State.REQUESTED
|
|
35
|
+
private var launchAttempted = false
|
|
36
|
+
private var flowEnded = false
|
|
37
|
+
|
|
38
|
+
/** A non-billing activity paused after the launch attempt, with no billing activity seen yet. */
|
|
39
|
+
private var hostPaused = false
|
|
40
|
+
|
|
41
|
+
/** The last fact of the flow that has ended, for its call's answer. */
|
|
42
|
+
private var lastFact: Pair<String, String>? = null
|
|
43
|
+
|
|
44
|
+
val active: Boolean get() = presentationId != null
|
|
45
|
+
|
|
46
|
+
/** The id of the flow whose sheet is open now, if any. */
|
|
47
|
+
val openPresentationId: String? get() = if (state == State.OPEN) presentationId else null
|
|
48
|
+
|
|
49
|
+
fun begin(id: String) {
|
|
50
|
+
presentationId = id
|
|
51
|
+
state = State.REQUESTED
|
|
52
|
+
launchAttempted = false
|
|
53
|
+
flowEnded = false
|
|
54
|
+
hostPaused = false
|
|
55
|
+
lastFact = null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether [id] is the flow being tracked; a flow ended early must not launch. */
|
|
59
|
+
fun isTracking(id: String): Boolean = presentationId == id
|
|
60
|
+
|
|
61
|
+
/** The flow of [id] is about to call `launchBillingFlow` (through OpenIAP). */
|
|
62
|
+
fun onLaunchAttempted(id: String) {
|
|
63
|
+
if (presentationId == id) {
|
|
64
|
+
launchAttempted = true
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fun onActivityCreated(className: String) {
|
|
69
|
+
val id = presentationId ?: return
|
|
70
|
+
if (state == State.REQUESTED && isBillingActivity(className)) {
|
|
71
|
+
state = State.OPEN
|
|
72
|
+
hostPaused = false
|
|
73
|
+
emit(id, OPEN, null)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fun onActivityPaused(className: String) {
|
|
78
|
+
if (presentationId != null && state == State.REQUESTED && launchAttempted &&
|
|
79
|
+
!isBillingActivity(className)
|
|
80
|
+
) {
|
|
81
|
+
hostPaused = true
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
fun onActivityResumed(className: String) {
|
|
86
|
+
if (presentationId == null || isBillingActivity(className)) return
|
|
87
|
+
if (state == State.OPEN) {
|
|
88
|
+
end(CLOSED, null)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
// Back without a billing activity: whatever covered the game was not
|
|
92
|
+
// the sheet. An ended flow is decided now.
|
|
93
|
+
hostPaused = false
|
|
94
|
+
if (flowEnded) {
|
|
95
|
+
end(NOT_STARTED, null)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* True while a launch was attempted, the flow is still running, and no
|
|
101
|
+
* billing activity was seen: the activity `launchBillingFlow` started may
|
|
102
|
+
* not be created yet (the game's activity may not even be paused yet), so
|
|
103
|
+
* the plugin waits briefly before it ends the flow.
|
|
104
|
+
*/
|
|
105
|
+
fun awaitsLaunchObservation(id: String): Boolean =
|
|
106
|
+
presentationId == id && state == State.REQUESTED && launchAttempted && !flowEnded
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The OpenIAP flow of [id] returned (or never started). Decides
|
|
110
|
+
* `not-started` when nothing opened, unless a launch was attempted and
|
|
111
|
+
* the game's activity is still paused: then the verdict waits for it to
|
|
112
|
+
* resume (`not-started`), a billing activity (`open`), or [onLateBound].
|
|
113
|
+
*/
|
|
114
|
+
fun onFlowEnded(id: String) {
|
|
115
|
+
if (presentationId != id || state != State.REQUESTED) return
|
|
116
|
+
flowEnded = true
|
|
117
|
+
if (!launchAttempted || !hostPaused) {
|
|
118
|
+
end(NOT_STARTED, null)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The plugin's bound on a deferred verdict passed: nothing opened. */
|
|
123
|
+
fun onLateBound(id: String) {
|
|
124
|
+
if (presentationId == id && state == State.REQUESTED && flowEnded) {
|
|
125
|
+
end(NOT_STARTED, null)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The plugin's call timeout for [id] before `launchBillingFlow` was ever
|
|
131
|
+
* called: the flow is ended `not-started` (the plugin cancels it, and a
|
|
132
|
+
* flow no longer tracked never launches). True when it ended the flow.
|
|
133
|
+
*/
|
|
134
|
+
fun cancelBeforeLaunch(id: String): Boolean {
|
|
135
|
+
if (presentationId != id || state != State.REQUESTED || launchAttempted) return false
|
|
136
|
+
end(NOT_STARTED, null)
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The sheet of [id] stayed open past the plugin's safety bound without the
|
|
142
|
+
* host resuming: report it closed (`timeout`) so a new flow can start.
|
|
143
|
+
*/
|
|
144
|
+
fun onSafetyTimeout(id: String) {
|
|
145
|
+
if (presentationId == id && state == State.OPEN) {
|
|
146
|
+
end(CLOSED, TIMEOUT)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The plugin is torn down: nothing will be observed any more. When the
|
|
152
|
+
* activity is only recreated ([reportClosed] false, a configuration
|
|
153
|
+
* change), the sheet may still be up, so no `closed` is claimed: the
|
|
154
|
+
* flow is forgotten and the TypeScript provider's safety timeout ends it
|
|
155
|
+
* for a web view that survives.
|
|
156
|
+
*/
|
|
157
|
+
fun onTeardown(reportClosed: Boolean = true) {
|
|
158
|
+
if (presentationId == null) return
|
|
159
|
+
if (!reportClosed) {
|
|
160
|
+
presentationId = null
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
if (state == State.OPEN || launchAttempted) {
|
|
164
|
+
end(CLOSED, INTERRUPTED)
|
|
165
|
+
} else {
|
|
166
|
+
end(NOT_STARTED, null)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* What the call's answer reports for [id]: `open` or `closed` or
|
|
172
|
+
* `not-started` for the current or just ended flow, and `requested` while
|
|
173
|
+
* the flow runs without a verdict yet (facts follow as events).
|
|
174
|
+
*/
|
|
175
|
+
fun answerState(id: String): String {
|
|
176
|
+
if (presentationId == id) {
|
|
177
|
+
return if (state == State.OPEN) OPEN else REQUESTED_STATE
|
|
178
|
+
}
|
|
179
|
+
val last = lastFact
|
|
180
|
+
return if (last != null && last.first == id) last.second else REQUESTED_STATE
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private fun end(fact: String, reason: String?) {
|
|
184
|
+
val id = presentationId ?: return
|
|
185
|
+
presentationId = null
|
|
186
|
+
lastFact = id to fact
|
|
187
|
+
emit(id, fact, reason)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
companion object {
|
|
191
|
+
const val OPEN = "open"
|
|
192
|
+
const val CLOSED = "closed"
|
|
193
|
+
const val NOT_STARTED = "not-started"
|
|
194
|
+
const val REQUESTED_STATE = "requested"
|
|
195
|
+
const val INTERRUPTED = "interrupted"
|
|
196
|
+
const val TIMEOUT = "timeout"
|
|
197
|
+
|
|
198
|
+
/** The protocol version `getPresentationSupport` declares. */
|
|
199
|
+
const val PROTOCOL_VERSION = 1
|
|
200
|
+
|
|
201
|
+
private const val BILLING_ACTIVITY_PREFIX = "com.android.billingclient."
|
|
202
|
+
private val presentationIdPattern = Regex("^[A-Za-z0-9-]{1,64}$")
|
|
203
|
+
|
|
204
|
+
fun isBillingActivity(className: String): Boolean =
|
|
205
|
+
className.startsWith(BILLING_ACTIVITY_PREFIX)
|
|
206
|
+
|
|
207
|
+
fun isValidPresentationId(value: String?): Boolean =
|
|
208
|
+
value != null && presentationIdPattern.matches(value)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -47,6 +47,15 @@ export interface CreateCapacitorPlayBillingCommerceProviderInput {
|
|
|
47
47
|
readonly mapError?: StoreSdkErrorCodeMap;
|
|
48
48
|
/** Diagnostics for a retired SKU or a multi-line purchase. The purchase is still surfaced; see the SDK input. */
|
|
49
49
|
readonly onUnmappedPurchase?: (purchase: PlayPurchase) => void;
|
|
50
|
+
/**
|
|
51
|
+
* Play sheet facts (default true): the provider offers `presentation`,
|
|
52
|
+
* observed from the activity lifecycle, so the gateway and the game
|
|
53
|
+
* runtime pause until the Play sheet is gone, not until a result. Needs an
|
|
54
|
+
* app built with this package's native code (otherwise new purchases are
|
|
55
|
+
* refused with `PLAY_BILLING_PRESENTATION_UNSUPPORTED`). False keeps the
|
|
56
|
+
* result-based pause of earlier releases.
|
|
57
|
+
*/
|
|
58
|
+
readonly purchasePresentation?: boolean;
|
|
50
59
|
}
|
|
51
60
|
/**
|
|
52
61
|
* Commerce v2 `CommerceProvider` for Google Play: `createStoreCommerceProvider`
|
|
@@ -43,6 +43,9 @@ export function createCapacitorPlayBillingCommerceProvider(input) {
|
|
|
43
43
|
...(input.onUnmappedPurchase === undefined
|
|
44
44
|
? {}
|
|
45
45
|
: { onUnmappedPurchase: input.onUnmappedPurchase }),
|
|
46
|
+
...(input.purchasePresentation === undefined
|
|
47
|
+
? {}
|
|
48
|
+
: { purchasePresentation: input.purchasePresentation }),
|
|
46
49
|
});
|
|
47
50
|
const resolveAccountBinding = input.accountBinding;
|
|
48
51
|
const gate = resolvePurchaseGateSupport(input.purchaseGate);
|
package/dist/definitions.d.ts
CHANGED
|
@@ -55,6 +55,33 @@ export interface PlayPurchaseOutcome {
|
|
|
55
55
|
readonly productId?: string;
|
|
56
56
|
/** The `PLAY_BILLING_*` code of a late `failed` result. */
|
|
57
57
|
readonly code?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The Play sheet when a `purchase` call with a `presentationId` answered
|
|
60
|
+
* (also in a rejection's `data`): `open` (still up; `closed` follows as a
|
|
61
|
+
* `purchasePresentation` event), `closed`, `not-started`, or `requested`
|
|
62
|
+
* (no fact yet; facts follow as events).
|
|
63
|
+
*/
|
|
64
|
+
readonly presentation?: PlayPurchaseAnswerPresentation;
|
|
65
|
+
}
|
|
66
|
+
export type PlayPurchasePresentationState = 'open' | 'closed' | 'not-started';
|
|
67
|
+
export type PlayPurchaseAnswerPresentation = PlayPurchasePresentationState | 'requested';
|
|
68
|
+
/**
|
|
69
|
+
* A Play sheet fact for the `purchase` call that sent `presentationId`,
|
|
70
|
+
* observed from the activity lifecycle and never from a purchase result:
|
|
71
|
+
* `open` once the Billing Library's activity is created, `closed` when the
|
|
72
|
+
* game's activity resumes after it (reason `interrupted` when the plugin was
|
|
73
|
+
* torn down, `timeout` past its 15-minute bound), `not-started` when the flow
|
|
74
|
+
* ended without it.
|
|
75
|
+
*/
|
|
76
|
+
export interface PlayPurchasePresentationEvent {
|
|
77
|
+
readonly presentationId: string;
|
|
78
|
+
readonly state: PlayPurchasePresentationState;
|
|
79
|
+
/** Teardown, or the plugin's 15-minute bound on one sheet without the game's activity resuming. */
|
|
80
|
+
readonly reason?: 'interrupted' | 'timeout';
|
|
81
|
+
}
|
|
82
|
+
/** What `getPresentationSupport` declares; `purchasePresentation` is the facts protocol version. */
|
|
83
|
+
export interface PlayPresentationSupport {
|
|
84
|
+
readonly purchasePresentation: number;
|
|
58
85
|
}
|
|
59
86
|
export interface CapacitorPlayBillingPlugin {
|
|
60
87
|
getProducts(input: {
|
|
@@ -66,8 +93,16 @@ export interface CapacitorPlayBillingPlugin {
|
|
|
66
93
|
readonly productId: string;
|
|
67
94
|
readonly offerToken?: string;
|
|
68
95
|
readonly obfuscatedAccountId: string;
|
|
96
|
+
/** Asks for this call's sheet facts (`purchasePresentation`); 1-64 of `[A-Za-z0-9-]`. */
|
|
97
|
+
readonly presentationId?: string;
|
|
69
98
|
}): Promise<PlayPurchaseOutcome>;
|
|
70
99
|
/**
|
|
100
|
+
* Declares the sheet facts the native code reports. A native build from
|
|
101
|
+
* before them answers `UNIMPLEMENTED`; optional so a plugin double without
|
|
102
|
+
* it still type-checks, and then the SDK refuses purchases that need facts.
|
|
103
|
+
*/
|
|
104
|
+
getPresentationSupport?(): Promise<PlayPresentationSupport>;
|
|
105
|
+
/**
|
|
71
106
|
* Requeries every purchased or pending in-app purchase Play still reports
|
|
72
107
|
* for this user and device (`queryPurchasesAsync`): unconsumed consumables,
|
|
73
108
|
* acknowledged and unacknowledged durables, and pending purchases. The
|
|
@@ -83,4 +118,6 @@ export interface CapacitorPlayBillingPlugin {
|
|
|
83
118
|
* until the first listener consumes them.
|
|
84
119
|
*/
|
|
85
120
|
addListener(eventName: 'purchaseUpdated', listener: (event: PlayPurchaseOutcome) => void): Promise<PluginListenerHandle>;
|
|
121
|
+
/** Sheet facts of `purchase` calls that sent a `presentationId`; retained until a listener attaches. */
|
|
122
|
+
addListener(eventName: 'purchasePresentation', listener: (event: PlayPurchasePresentationEvent) => void): Promise<PluginListenerHandle>;
|
|
86
123
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PluginListenerHandle } from '@capacitor/core';
|
|
2
|
+
export type PlayBillingPurchasePresentationState = 'open' | 'closed' | 'not-started' | 'unknown';
|
|
3
|
+
/** A sheet fact of one `requestPurchase` call; see `StoreSdkPurchasePresentationEvent`. */
|
|
4
|
+
export interface PlayBillingSdkPurchasePresentationEvent {
|
|
5
|
+
readonly presentationId: string;
|
|
6
|
+
readonly state: PlayBillingPurchasePresentationState;
|
|
7
|
+
readonly reason?: 'interrupted' | 'timeout';
|
|
8
|
+
}
|
|
9
|
+
/** The SDK's `purchasePresentation`; see `StoreSdkPurchasePresentation` in `@mpgd/adapter-capacitor`. */
|
|
10
|
+
export interface PlayBillingSdkPurchasePresentation {
|
|
11
|
+
subscribe(listener: (event: PlayBillingSdkPurchasePresentationEvent) => void): () => void;
|
|
12
|
+
}
|
|
13
|
+
/** See `NativePurchasePresentation` in `@mpgd/adapter-capacitor`. */
|
|
14
|
+
export interface PlayBillingNativePurchasePresentation {
|
|
15
|
+
readonly purchasePresentation: PlayBillingSdkPurchasePresentation;
|
|
16
|
+
supported(): Promise<boolean>;
|
|
17
|
+
followed(): boolean;
|
|
18
|
+
begin(presentationId: string): void;
|
|
19
|
+
answered(presentationId: string, answerOrError: unknown): void;
|
|
20
|
+
}
|
|
21
|
+
/** The native surface the facts need: the plugin's event and support check. */
|
|
22
|
+
export interface PlayBillingNativePresentationPlugin {
|
|
23
|
+
addListener(eventName: 'purchasePresentation', listener: (event: unknown) => void): Promise<PluginListenerHandle>;
|
|
24
|
+
readonly getPresentationSupport?: () => Promise<unknown>;
|
|
25
|
+
}
|
|
26
|
+
/** The adapter's facts helper over `native`, or undefined with an adapter from before it. */
|
|
27
|
+
export declare function resolvePlayBillingNativePurchasePresentation(native: PlayBillingNativePresentationPlugin, adapterExports?: object): PlayBillingNativePurchasePresentation | undefined;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as adapterCapacitor from '@mpgd/adapter-capacitor';
|
|
2
|
+
/** The adapter's facts helper over `native`, or undefined with an adapter from before it. */
|
|
3
|
+
export function resolvePlayBillingNativePurchasePresentation(native, adapterExports = adapterCapacitor) {
|
|
4
|
+
const factory = adapterExports.createNativePurchasePresentation;
|
|
5
|
+
if (typeof factory !== 'function') {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
return factory(native);
|
|
9
|
+
}
|
package/dist/store-sdk.d.ts
CHANGED
|
@@ -2,12 +2,22 @@ import type { PlatformProviderAvailability } from '@mpgd/platform';
|
|
|
2
2
|
import { type StoreSdk, type StoreSdkError, type StoreSdkErrorCodeMap, type StoreSdkPurchase } from '@mpgd/adapter-capacitor';
|
|
3
3
|
import type { CapacitorPlayBillingPlugin, PlayPurchase } from './definitions.js';
|
|
4
4
|
import { googlePlayStoreId, playBillingStoreSdkCapabilities } from './commerce-profile.js';
|
|
5
|
+
import { type PlayBillingSdkPurchasePresentation } from './purchase-presentation.js';
|
|
6
|
+
export type { PlayBillingPurchasePresentationState, PlayBillingSdkPurchasePresentation, PlayBillingSdkPurchasePresentationEvent, } from './purchase-presentation.js';
|
|
5
7
|
export { googlePlayStoreId, playBillingStoreSdkCapabilities };
|
|
6
8
|
/**
|
|
7
9
|
* `defaultStoreSdkErrorCodeMap` plus every `PLAY_BILLING_*` code the plugin or
|
|
8
10
|
* this SDK emits that the default map does not already normalize.
|
|
9
11
|
*/
|
|
10
12
|
export declare const playBillingStoreSdkErrorCodeMap: StoreSdkErrorCodeMap;
|
|
13
|
+
/**
|
|
14
|
+
* The code of a purchase refused because the native plugin in the app binary
|
|
15
|
+
* reports no sheet facts (a web bundle newer than its native build). The
|
|
16
|
+
* provider offers `presentation`, so a purchase it could not follow is not
|
|
17
|
+
* sold; rebuilding the app with this package's native code, or
|
|
18
|
+
* `purchasePresentation: false`, sells again. Recovery is unaffected.
|
|
19
|
+
*/
|
|
20
|
+
export declare const playBillingPresentationUnsupportedCode: 'PLAY_BILLING_PRESENTATION_UNSUPPORTED';
|
|
11
21
|
/** One Play product the SDK sells; the provider maps it to a logical id. */
|
|
12
22
|
export interface PlayBillingStoreSdkProduct {
|
|
13
23
|
/** The Play product id (SKU). */
|
|
@@ -35,10 +45,20 @@ export interface CreateCapacitorPlayBillingStoreSdkInput {
|
|
|
35
45
|
* acknowledge one without a logical product id.
|
|
36
46
|
*/
|
|
37
47
|
readonly onUnmappedPurchase?: (purchase: PlayPurchase) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Report the Play sheet's facts (`purchasePresentation`), so the adapter's
|
|
50
|
+
* provider offers `CommerceProvider.presentation` and the game pauses until
|
|
51
|
+
* the sheet is gone rather than until a result. Defaults to true. A native
|
|
52
|
+
* build without the facts then refuses new purchases with
|
|
53
|
+
* `PLAY_BILLING_PRESENTATION_UNSUPPORTED`; false keeps the result-based
|
|
54
|
+
* pause of earlier releases.
|
|
55
|
+
*/
|
|
56
|
+
readonly purchasePresentation?: boolean;
|
|
38
57
|
}
|
|
39
58
|
export interface CapacitorPlayBillingStoreSdk extends StoreSdk {
|
|
40
59
|
readonly store: typeof googlePlayStoreId;
|
|
41
60
|
getAvailability(): Promise<PlatformProviderAvailability>;
|
|
61
|
+
readonly purchasePresentation?: PlayBillingSdkPurchasePresentation;
|
|
42
62
|
}
|
|
43
63
|
interface PlayBillingStoreSdkErrorOptions {
|
|
44
64
|
readonly retryable?: boolean;
|
package/dist/store-sdk.js
CHANGED
|
@@ -3,6 +3,7 @@ import { defaultStoreSdkErrorCodeMap, } from '@mpgd/adapter-capacitor';
|
|
|
3
3
|
import { createGooglePlayTokenPurchaseKey } from '@mpgd/platform/commerce';
|
|
4
4
|
import { googlePlayStoreId, playBillingStoreSdkCapabilities } from './commerce-profile.js';
|
|
5
5
|
import { CapacitorPlayBilling } from './plugin.js';
|
|
6
|
+
import { resolvePlayBillingNativePurchasePresentation, } from './purchase-presentation.js';
|
|
6
7
|
// The profile constants live in the SDK-free `commerce-profile.ts` so a tools
|
|
7
8
|
// project can read them without loading Capacitor; they stay exported here.
|
|
8
9
|
export { googlePlayStoreId, playBillingStoreSdkCapabilities };
|
|
@@ -19,8 +20,17 @@ export const playBillingStoreSdkErrorCodeMap = Object.freeze({
|
|
|
19
20
|
PLAY_BILLING_INVALID_REQUEST: 'CONFIGURATION_REQUIRED',
|
|
20
21
|
PLAY_BILLING_ACTIVITY_REQUIRED: 'NOT_PREPARED',
|
|
21
22
|
PLAY_BILLING_ANDROID_REQUIRED: 'STORE_UNAVAILABLE',
|
|
23
|
+
PLAY_BILLING_PRESENTATION_UNSUPPORTED: 'CONFIGURATION_REQUIRED',
|
|
22
24
|
PLAY_BILLING_ERROR: 'UNKNOWN',
|
|
23
25
|
});
|
|
26
|
+
/**
|
|
27
|
+
* The code of a purchase refused because the native plugin in the app binary
|
|
28
|
+
* reports no sheet facts (a web bundle newer than its native build). The
|
|
29
|
+
* provider offers `presentation`, so a purchase it could not follow is not
|
|
30
|
+
* sold; rebuilding the app with this package's native code, or
|
|
31
|
+
* `purchasePresentation: false`, sells again. Recovery is unaffected.
|
|
32
|
+
*/
|
|
33
|
+
export const playBillingPresentationUnsupportedCode = 'PLAY_BILLING_PRESENTATION_UNSUPPORTED';
|
|
24
34
|
const productIdPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
25
35
|
const obfuscatedAccountIdPattern = /^[A-Za-z0-9._-]{1,64}$/u;
|
|
26
36
|
const maximumProducts = 100;
|
|
@@ -261,9 +271,14 @@ export function createCapacitorPlayBillingStoreSdk(input) {
|
|
|
261
271
|
pluginListenerGeneration += 1;
|
|
262
272
|
handle.then((resolved) => resolved.remove()).catch(() => undefined);
|
|
263
273
|
};
|
|
274
|
+
// Sheet facts of `purchase` calls that carry a presentation id.
|
|
275
|
+
const presentation = input.purchasePresentation === false
|
|
276
|
+
? undefined
|
|
277
|
+
: resolvePlayBillingNativePurchasePresentation(plugin);
|
|
264
278
|
const sdk = {
|
|
265
279
|
store: googlePlayStoreId,
|
|
266
280
|
capabilities: playBillingStoreSdkCapabilities,
|
|
281
|
+
...(presentation === undefined ? {} : { purchasePresentation: presentation.purchasePresentation }),
|
|
267
282
|
async fetchProducts(request) {
|
|
268
283
|
assertAndroid(isAndroid);
|
|
269
284
|
if (request.type === 'subscription') {
|
|
@@ -322,17 +337,34 @@ export function createCapacitorPlayBillingStoreSdk(input) {
|
|
|
322
337
|
throw new PlayBillingStoreSdkError('PLAY_BILLING_ACCOUNT_REQUIRED', 'Play Billing requires an obfuscated account id that agrees with the backend resolver.');
|
|
323
338
|
}
|
|
324
339
|
const offerToken = request.offerId ?? product.offerToken;
|
|
340
|
+
// Only an adapter that reads sheet facts sends an id (one from before
|
|
341
|
+
// them never does, and the purchase runs as in earlier releases).
|
|
342
|
+
const presentationId = presentation === undefined ? undefined : request.presentationId;
|
|
343
|
+
if (presentation !== undefined && presentationId !== undefined) {
|
|
344
|
+
if (!(await presentation.supported())) {
|
|
345
|
+
throw new PlayBillingStoreSdkError(playBillingPresentationUnsupportedCode, 'The app was built with a Play Billing plugin that reports no purchase sheet facts; '
|
|
346
|
+
+ "rebuild it with this package's native code.", { retryable: false });
|
|
347
|
+
}
|
|
348
|
+
presentation.begin(presentationId);
|
|
349
|
+
}
|
|
325
350
|
let outcome;
|
|
326
351
|
try {
|
|
327
352
|
outcome = await plugin.purchase({
|
|
328
353
|
productId: product.sku,
|
|
329
354
|
obfuscatedAccountId: binding.value,
|
|
330
355
|
...(offerToken === undefined ? {} : { offerToken }),
|
|
356
|
+
...(presentationId === undefined ? {} : { presentationId }),
|
|
331
357
|
});
|
|
332
358
|
}
|
|
333
359
|
catch (error) {
|
|
360
|
+
if (presentationId !== undefined) {
|
|
361
|
+
presentation?.answered(presentationId, error);
|
|
362
|
+
}
|
|
334
363
|
return { outcome: 'failed', error: toSdkError(error) };
|
|
335
364
|
}
|
|
365
|
+
if (presentationId !== undefined) {
|
|
366
|
+
presentation?.answered(presentationId, outcome);
|
|
367
|
+
}
|
|
336
368
|
if (!isRecord(outcome)) {
|
|
337
369
|
throw invalidResponse('Play returned an invalid purchase outcome.');
|
|
338
370
|
}
|
|
@@ -396,6 +428,12 @@ export function createCapacitorPlayBillingStoreSdk(input) {
|
|
|
396
428
|
if (!isAndroid()) {
|
|
397
429
|
return 'unsupported';
|
|
398
430
|
}
|
|
431
|
+
// An adapter that reads sheet facts over a native build without them
|
|
432
|
+
// would refuse every purchase: report the store as needing a rebuild,
|
|
433
|
+
// so the shop sells nothing and no idempotency key is spent.
|
|
434
|
+
if (presentation !== undefined && presentation.followed() && !(await presentation.supported())) {
|
|
435
|
+
return 'configuration-required';
|
|
436
|
+
}
|
|
399
437
|
try {
|
|
400
438
|
const response = await plugin.getProducts({ productIds: [...products.keys()] });
|
|
401
439
|
const purchasable = readArray(response, 'products').some((found) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/capacitor-play-billing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Opt-in Android Google Play Billing purchase collector for mpgd Capacitor games.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
],
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@capacitor/core": "^8.5.1",
|
|
41
|
-
"@mpgd/adapter-capacitor": "^0.
|
|
42
|
-
"@mpgd/bridge": "^0.
|
|
43
|
-
"@mpgd/platform": "^0.
|
|
41
|
+
"@mpgd/adapter-capacitor": "^0.18.0",
|
|
42
|
+
"@mpgd/bridge": "^0.18.0",
|
|
43
|
+
"@mpgd/platform": "^0.34.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@capacitor/core": "8.5.2",
|
|
@@ -48,10 +48,10 @@
|
|
|
48
48
|
"ttsc": "0.34.2",
|
|
49
49
|
"typescript": "7.0.2",
|
|
50
50
|
"vitest": "^4.0.0",
|
|
51
|
-
"@mpgd/adapter-capacitor": "0.
|
|
52
|
-
"@mpgd/bridge": "0.
|
|
53
|
-
"@mpgd/platform": "0.
|
|
54
|
-
"@mpgd/game-services": "0.
|
|
51
|
+
"@mpgd/adapter-capacitor": "0.18.0",
|
|
52
|
+
"@mpgd/bridge": "0.18.0",
|
|
53
|
+
"@mpgd/platform": "0.34.0",
|
|
54
|
+
"@mpgd/game-services": "0.32.0"
|
|
55
55
|
},
|
|
56
56
|
"main": "./dist/index.js",
|
|
57
57
|
"types": "./dist/index.d.ts",
|