@mpgd/capacitor-play-billing 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.reject("Product and account identifiers are required.", "PLAY_BILLING_INVALID_REQUEST")
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
- if (activePurchase != null || flowInProgress) {
141
- call.reject("Another purchase is already in progress.", "PLAY_BILLING_BUSY")
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
- call.reject("Purchase confirmation timed out; requery owned purchases.", "PLAY_BILLING_TIMEOUT")
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
- 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))
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
- } catch (error: Exception) {
168
- logUnexpected("purchase", error)
169
- PurchaseResolution.Failed("PLAY_BILLING_ERROR", "Google Play Billing failed.")
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(call: PluginCall, productId: String, resolution: PurchaseResolution) {
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);
@@ -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
+ }
@@ -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.5.0",
3
+ "version": "0.6.1",
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.17.0",
42
- "@mpgd/bridge": "^0.17.0",
43
- "@mpgd/platform": "^0.33.0"
41
+ "@mpgd/adapter-capacitor": "^0.19.0",
42
+ "@mpgd/bridge": "^0.18.0",
43
+ "@mpgd/platform": "^0.35.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.17.0",
52
- "@mpgd/bridge": "0.17.0",
53
- "@mpgd/platform": "0.33.0",
54
- "@mpgd/game-services": "0.31.0"
51
+ "@mpgd/adapter-capacitor": "0.19.0",
52
+ "@mpgd/game-services": "0.33.0",
53
+ "@mpgd/platform": "0.35.0",
54
+ "@mpgd/bridge": "0.18.1"
55
55
  },
56
56
  "main": "./dist/index.js",
57
57
  "types": "./dist/index.d.ts",