@mpgd/adapter-capacitor 0.17.0 → 0.19.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 CHANGED
@@ -148,14 +148,11 @@ seconds): the sales flag is unknown rather than closed. An answer that
148
148
  arrives after the timeout is ignored and never opens the store. A thrown
149
149
  `CommerceError` keeps its own code, except a code that claims a launched or
150
150
  cancelled sheet (`OUTCOME_UNKNOWN`, `ALREADY_OWNED`, `USER_CANCELLED`), which
151
- becomes `PURCHASE_GATE_FAILED` because nothing was launched. The reconciler
152
- journals a refused attempt as terminal `failed` even when it is retryable:
153
- only trusted presentation facts (`not-started`) keep a key reusable, and the
154
- Capacitor bridge does not carry them yet, so a later try uses a new
155
- idempotency key. The store provider deliberately exposes no `presentation`
156
- that could only ever say `not-started`, because the game runtime's purchase
157
- coordinator would take it as a closure source and quarantine the game after
158
- every launched purchase. The gate runs while the gateway already holds the
151
+ becomes `PURCHASE_GATE_FAILED` because nothing was launched. Over an SDK
152
+ with sheet facts (below), a refusal is reported `not-started` before the
153
+ answer, so the reconciler keeps a retryable refusal's key reusable and the
154
+ same key asks again; without sheet facts the reconciler journals the refused
155
+ attempt as terminal `failed`, and a later try uses a new idempotency key. The gate runs while the gateway already holds the
159
156
  purchase's external activity (the game is paused, with no sheet shown), so it
160
157
  should answer from a flag the game already holds rather than a slow network
161
158
  call; the timeout bounds that pause. Recovery, restore, finishing, events, `fetchProducts`, and
@@ -187,6 +184,50 @@ cannot advertise one the SDK lacks: `restore: true` without
187
184
  `sdk.restorePurchases`, like `finish: 'client' | 'host'` without
188
185
  `sdk.finishTransaction`, is rejected when the provider is created.
189
186
 
187
+ ### Store sheet facts
188
+
189
+ A `StoreSdk` that declares `purchasePresentation` reports, for a
190
+ `requestPurchase` call that carries a `presentationId`, when the store sheet
191
+ opens and when it is gone (`open`, then `closed`), or that none opened
192
+ (`not-started`). The Play Billing and StoreKit SDKs declare it by default.
193
+ Only then does the provider offer `CommerceProvider.presentation`, so a new
194
+ adapter over an SDK from before the facts keeps the behavior below, and an
195
+ SDK never receives a `presentationId` from an adapter that predates them.
196
+
197
+ The provider answers every `requestPurchase` with facts keyed by its
198
+ idempotency key, numbered with one increasing `sequence`:
199
+
200
+ - `not-started`, delivered before the answer, for a call that opened no
201
+ sheet: a purchase gate or account binding refusal, an unavailable store,
202
+ any throw, the SDK's own `not-started`, or an SDK that settled without a
203
+ fact.
204
+ - `unknown` when the SDK answers while its native flow still runs and has
205
+ observed nothing (a call timeout): the call keeps waiting for the SDK's
206
+ `open`, `closed`, or `not-started`, bounded by the safety timeout below,
207
+ instead of reading the answer as either.
208
+ - Otherwise `open`, then exactly one `closed`: the SDK's, or after
209
+ `purchasePresentationTimeoutMs` (15 minutes, `reason: 'timeout'`), or on
210
+ `dispose()` (`reason: 'disposed'`). A `not-started` after `open` becomes
211
+ `closed`. A result, a pending purchase, an Ask to Buy, or a purchase event
212
+ is never a `closed` fact.
213
+
214
+ `createCommerceProviderBridge` serves the facts through
215
+ `subscribePresentation` as `{ protocol: 'mpgd.commerce.presentation.v1',
216
+ sequence, event }` envelopes, apart from the commerce envelopes of
217
+ `subscribe`, so a commerce listener (and the host's buffering of purchase
218
+ events for its first listener) is unchanged. `createBridgeCommerceProvider`
219
+ offers `presentation` only when its input has `subscribePresentation`; it
220
+ validates and renumbers the facts and adds `not-started` for a request the
221
+ bridge refused before the host's provider ran. The gateway passes
222
+ `subscribePresentation` only from a provider bridge that has it. With facts,
223
+ the game runtime's purchase coordination turns on by itself
224
+ (`createGamePlatformRuntime`: `createCoordinatedPlatformGateway` with an ad
225
+ provider, `createCoordinatedCommerceProvider` without one), and the gateway
226
+ keeps a purchase's pause (`beginExternalActivity()`) until the sheet's
227
+ `closed` or `not-started`, whatever the answer, with
228
+ `presentedPurchaseActivityTimeoutMs` as a safety net: an Ask to Buy whose
229
+ sheet closed holds nothing.
230
+
190
231
  `createCommerceServiceProvider({ id, commerce })` wraps the provider as a
191
232
  `CapacitorServiceProvider` whose `commerce` field the registry recognizes. It
192
233
  declares no `features` and no `methods`: the six `commerce.v2.*` bridge methods
@@ -210,11 +251,12 @@ read per snapshot (`getCapabilitySnapshot` returns `{ availability, commerce }`,
210
251
  three-second timeout, shape validated): a stalled, failing, or invalid
211
252
  provider yields a degraded block (`createDegradedCommerceCapabilities`:
212
253
  `availability: 'temporarily-unavailable'`, nothing sold) and never rejects the
213
- capability read. A `launched` purchase keeps `beginExternalActivity()` open
214
- after the request returns, since the store UI is still up: the gateway releases
215
- it when the provider reports that product (`purchaseUpdated` or
216
- `purchaseError`), after `launchedPurchaseActivityTimeoutMs` (two minutes), or
217
- on `lifecycle.dispose`. The registry does not gate a commerce method by a
254
+ capability read. Without sheet facts, a `launched` purchase keeps
255
+ `beginExternalActivity()` open after the request returns, since the store UI
256
+ may still be up: the gateway releases it when the provider reports that
257
+ product (`purchaseUpdated` or `purchaseError`), after
258
+ `launchedPurchaseActivityTimeoutMs` (two minutes), or on `lifecycle.dispose`.
259
+ With facts, the sheet's own `closed` ends the pause (see above). The registry does not gate a commerce method by a
218
260
  snapshot: the provider reads its own `availability` and fails closed before any
219
261
  store UI opens. A `commerce.v2.*` request that reaches a transport without a
220
262
  provider still answers `COMMERCE_V2_UNAVAILABLE`.
@@ -1,4 +1,4 @@
1
- import { type BridgeCommerceEvent, type BridgeCommerceV2Method, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
1
+ import { type BridgeCommerceEvent, type BridgeCommercePresentationEvent, type BridgeCommerceV2Method, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
2
  import { type CommerceBridgeMethods, type CommerceProvider, type StoreId } from '@mpgd/platform/commerce';
3
3
  import type { CapacitorCommerceServiceProvider } from './providers.js';
4
4
  /** Bridge error for a commerce v2 payload the typia assert rejected. */
@@ -9,6 +9,16 @@ export interface CommerceProviderBridge {
9
9
  request(input: BridgeRequest): Promise<BridgeResponse>;
10
10
  /** Delivers `{ protocol: 'mpgd.commerce.v2', sequence, event }`; `sequence` starts at 0 per bridge. */
11
11
  subscribe(listener: (event: BridgeCommerceEvent) => void): () => void;
12
+ /**
13
+ * Present when the provider has `presentation`: delivers its purchase
14
+ * presentation facts as `{ protocol: 'mpgd.commerce.presentation.v1',
15
+ * sequence, event }`, `sequence` from 0 per bridge. Facts are never
16
+ * buffered, and a fact of a call that opened no sheet reaches a listener
17
+ * before that call's answer. Kept apart from `subscribe` so a commerce
18
+ * listener sees only commerce envelopes, and a bridge without it tells a
19
+ * consumer that its host has no facts.
20
+ */
21
+ subscribePresentation?(listener: (event: BridgeCommercePresentationEvent) => void): () => void;
12
22
  }
13
23
  export interface CreateCommerceProviderBridgeInput {
14
24
  readonly provider: CommerceProvider;
@@ -51,11 +61,32 @@ export interface CreateBridgeCommerceProviderInput {
51
61
  * provider does not declare it and callers never send `retry`.
52
62
  */
53
63
  readonly acceptsFinishRetry?: boolean;
64
+ /**
65
+ * The bridge host accepts `FinishTransactionInput.revoked` (same contract,
66
+ * and its provider declares `acceptsRevokedFinish`). Absent, the provider
67
+ * does not declare it and callers never send `revoked`.
68
+ */
69
+ readonly acceptsRevokedFinish?: boolean;
70
+ /**
71
+ * The host's purchase presentation facts
72
+ * (`CommerceProviderBridge.subscribePresentation`), each fact of a call
73
+ * that opened no sheet delivered before that call's answer. Only with it
74
+ * does this provider offer `presentation`: a host built before the
75
+ * protocol, or one whose provider has no facts, leaves it out, and the
76
+ * consumer keeps its behavior without them.
77
+ */
78
+ readonly subscribePresentation?: (listener: (event: BridgeCommercePresentationEvent) => void) => () => void;
54
79
  }
55
80
  /**
56
81
  * `CommerceProvider` over a bridge that serves the `commerce.v2.*` methods and
57
82
  * the event envelope. Results are validated again on this side, so a transport
58
83
  * that forgets to is still fail-closed; bridge errors become `CommerceError`s
59
84
  * with the bridge code kept as `storeCode` when it is not already a v2 code.
85
+ *
86
+ * With `subscribePresentation`, it offers `presentation`: the host's facts,
87
+ * validated and numbered again on this side, plus `not-started`, delivered
88
+ * before the answer, for a `requestPurchase` that settles without any fact
89
+ * (the bridge refused it before the host's provider ran). Every call it
90
+ * forwards then ends with a fact.
60
91
  */
61
92
  export declare function createBridgeCommerceProvider(input: CreateBridgeCommerceProviderInput): CommerceProvider;
@@ -1,6 +1,6 @@
1
- import { bridgeCommerceEventProtocol, createBridgeError, isBridgeCommerceV2Method, } from '@mpgd/bridge';
2
- import { PlatformOperationError } from '@mpgd/platform';
3
- import { assertCommerceBridgeFetchProductsPayload, assertCommerceBridgeFetchProductsResult, assertCommerceBridgeFinishTransactionPayload, assertCommerceBridgeFinishTransactionResult, assertCommerceBridgeGetAvailablePurchasesPayload, assertCommerceBridgeGetAvailablePurchasesResult, assertCommerceBridgeGetCapabilitiesPayload, assertCommerceBridgeGetCapabilitiesResult, assertCommerceBridgeRequestPurchasePayload, assertCommerceBridgeRequestPurchaseResult, assertCommerceBridgeRestorePurchasesPayload, assertCommerceBridgeRestorePurchasesResult, assertCommerceEventEnvelope, CommerceError, commerceErrorInfoOf, commerceProtocol, commerceProtocolVersion, } from '@mpgd/platform/commerce';
1
+ import { bridgeCommerceEventProtocol, bridgeCommercePresentationEventProtocol, createBridgeError, isBridgeCommerceV2Method, } from '@mpgd/bridge';
2
+ import { createPurchasePresentationRelay, PlatformOperationError, } from '@mpgd/platform';
3
+ import { assertCommerceBridgeFetchProductsPayload, assertCommerceBridgeFetchProductsResult, assertCommerceBridgeFinishTransactionPayload, assertCommerceBridgeFinishTransactionResult, assertCommerceBridgeGetAvailablePurchasesPayload, assertCommerceBridgeGetAvailablePurchasesResult, assertCommerceBridgeGetCapabilitiesPayload, assertCommerceBridgeGetCapabilitiesResult, assertCommerceBridgeRequestPurchasePayload, assertCommerceBridgeRequestPurchaseResult, assertCommerceBridgeRestorePurchasesPayload, assertCommerceBridgeRestorePurchasesResult, assertCommerceEventEnvelope, assertCommercePresentationEventEnvelope, CommerceError, commerceErrorInfoOf, commerceProtocol, commerceProtocolVersion, } from '@mpgd/platform/commerce';
4
4
  import { namesOtherProtocol } from './bridge-events.js';
5
5
  import { normalizeStoreSdkError } from './store-sdk.js';
6
6
  /** Bridge error for a commerce v2 payload the typia assert rejected. */
@@ -19,8 +19,11 @@ export const commerceV2MethodUnsupportedBridgeErrorCode = 'COMMERCE_V2_METHOD_UN
19
19
  export function createCommerceProviderBridge(input) {
20
20
  const { provider } = input;
21
21
  const listeners = new Set();
22
+ const presentationListeners = new Set();
22
23
  let sequence = 0;
24
+ let presentationSequence = 0;
23
25
  let detach;
26
+ let detachPresentation;
24
27
  const deliver = (event) => {
25
28
  const envelope = { protocol: commerceProtocol, sequence, event };
26
29
  sequence += 1;
@@ -28,6 +31,35 @@ export function createCommerceProviderBridge(input) {
28
31
  listener(envelope);
29
32
  }
30
33
  };
34
+ const deliverPresentation = (event) => {
35
+ let envelope;
36
+ try {
37
+ envelope = assertCommercePresentationEventEnvelope({
38
+ protocol: bridgeCommercePresentationEventProtocol,
39
+ sequence: presentationSequence,
40
+ event,
41
+ });
42
+ }
43
+ catch (error) {
44
+ // A fact the contract rejects is not delivered; the provider's own
45
+ // safety timeout still ends a sheet it never reports closed.
46
+ console.warn('A commerce provider reported an invalid presentation fact.', error);
47
+ return;
48
+ }
49
+ presentationSequence += 1;
50
+ for (const entry of [...presentationListeners]) {
51
+ if (!presentationListeners.has(entry)) {
52
+ continue;
53
+ }
54
+ try {
55
+ entry.listener(envelope);
56
+ }
57
+ catch (error) {
58
+ // A failing listener must not keep a `closed` fact from the others.
59
+ console.warn('A purchase presentation bridge listener failed.', error);
60
+ }
61
+ }
62
+ };
31
63
  const serve = async (request) => {
32
64
  const { payload } = request;
33
65
  switch (request.method) {
@@ -101,6 +133,22 @@ export function createCommerceProviderBridge(input) {
101
133
  detach = undefined;
102
134
  };
103
135
  },
136
+ ...(provider.presentation === undefined ? {} : {
137
+ subscribePresentation(listener) {
138
+ const entry = { listener };
139
+ presentationListeners.add(entry);
140
+ if (presentationListeners.size === 1) {
141
+ detachPresentation = provider.presentation?.subscribe(deliverPresentation);
142
+ }
143
+ return () => {
144
+ if (!presentationListeners.delete(entry) || presentationListeners.size > 0) {
145
+ return;
146
+ }
147
+ detachPresentation?.();
148
+ detachPresentation = undefined;
149
+ };
150
+ },
151
+ }),
104
152
  };
105
153
  }
106
154
  /**
@@ -164,6 +212,12 @@ function isTypiaGuardError(error) {
164
212
  * the event envelope. Results are validated again on this side, so a transport
165
213
  * that forgets to is still fail-closed; bridge errors become `CommerceError`s
166
214
  * with the bridge code kept as `storeCode` when it is not already a v2 code.
215
+ *
216
+ * With `subscribePresentation`, it offers `presentation`: the host's facts,
217
+ * validated and numbered again on this side, plus `not-started`, delivered
218
+ * before the answer, for a `requestPurchase` that settles without any fact
219
+ * (the bridge refused it before the host's provider ran). Every call it
220
+ * forwards then ends with a fact.
167
221
  */
168
222
  export function createBridgeCommerceProvider(input) {
169
223
  const send = async (method, payload) => {
@@ -184,9 +238,10 @@ export function createBridgeCommerceProvider(input) {
184
238
  });
185
239
  }
186
240
  };
187
- return {
241
+ const provider = {
188
242
  store: input.store,
189
243
  ...(input.acceptsFinishRetry === true ? { acceptsFinishRetry: true } : {}),
244
+ ...(input.acceptsRevokedFinish === true ? { acceptsRevokedFinish: true } : {}),
190
245
  async getCapabilities() {
191
246
  return (await send('commerce.v2.getCapabilities', { protocol: commerceProtocol }))
192
247
  .capabilities;
@@ -232,6 +287,43 @@ export function createBridgeCommerceProvider(input) {
232
287
  });
233
288
  },
234
289
  };
290
+ const subscribePresentation = input.subscribePresentation;
291
+ return subscribePresentation === undefined
292
+ ? provider
293
+ : withBridgePresentation(provider, subscribePresentation);
294
+ }
295
+ /**
296
+ * `presentation` over the host's facts: validated, then relayed by the
297
+ * platform's `createPurchasePresentationRelay` (a superseded fact dropped,
298
+ * the rest numbered again, `not-started` added for a call that settled
299
+ * without a fact). One attachment to the host serves every listener.
300
+ */
301
+ function withBridgePresentation(provider, subscribePresentation) {
302
+ const relay = createPurchasePresentationRelay({
303
+ subscribe: (listener) => subscribePresentation((envelope) => {
304
+ if (namesOtherProtocol(envelope, bridgeCommercePresentationEventProtocol)) {
305
+ return;
306
+ }
307
+ let event;
308
+ try {
309
+ event = assertCommercePresentationEventEnvelope(envelope).event;
310
+ }
311
+ catch (error) {
312
+ console.warn('The bridge delivered an invalid purchase presentation fact.', error);
313
+ return;
314
+ }
315
+ listener(event);
316
+ }),
317
+ }, {
318
+ onListenerError: (error) => {
319
+ console.warn('A purchase presentation listener failed.', error);
320
+ },
321
+ });
322
+ return {
323
+ ...provider,
324
+ presentation: relay.presentation,
325
+ requestPurchase: (request) => relay.track(request.idempotencyKey, () => provider.requestPurchase(request)),
326
+ };
235
327
  }
236
328
  function toCommerceError(error) {
237
329
  if (error instanceof CommerceError) {
package/dist/index.d.ts CHANGED
@@ -3,19 +3,28 @@ import { type CapacitorAppEventsApi, type CapacitorIncomingUrlKind, type Capacit
3
3
  import { type CapacitorCrashReportingOptions } from './crash-reporting.js';
4
4
  import { type CapacitorServiceProvider, type NativeBridge } from './providers.js';
5
5
  export { capacitorCrashlyticsPluginName, capacitorCrashReportingProvider, createCapacitorCrashReporter, createLocalCrashCollectionPreferenceStore, type CrashCollectionPreferenceStore, type CapacitorCrashlyticsPlugin, type CapacitorCrashReporterOptions, type CapacitorCrashReportingOptions, } from './crash-reporting.js';
6
+ export { createNativePurchasePresentation, purchasePresentationTimeoutMs, type NativePurchasePresentation, type NativePurchasePresentationPlugin, } from './purchase-presentation.js';
6
7
  export { createCapacitorProviderRegistry, createDegradedCommerceCapabilities, createDegradedNotificationCapabilities, type CapacitorCommerceServiceProvider, type CapacitorNotificationServiceProvider, type CapacitorNotificationsV2Service, type CapacitorProviderCapabilitySnapshot, type CapacitorProviderRegistry, type CapacitorServiceProvider, type NativeBridge, } from './providers.js';
7
8
  export { assertCommerceBridgeResult, commerceV2InvalidPayloadBridgeErrorCode, commerceV2MethodUnsupportedBridgeErrorCode, createBridgeCommerceProvider, createCommerceProviderBridge, createCommerceServiceProvider, type CommerceProviderBridge, type CreateBridgeCommerceProviderInput, type CreateCommerceProviderBridgeInput, type CreateCommerceServiceProviderInput, } from './commerce-bridge.js';
8
9
  export { accountBindingLookupFailedStoreCode, createStoreCommerceProvider, purchaseGateFailedStoreCode, purchaseGateTimeoutMs, purchasesPausedStoreCode, recoverableErrorCodes, type CreateStoreCommerceProviderInput, type StoreCommerceCapabilityOverrides, type StoreCommerceProductMapping, type StoreCommerceProvider, type StorePurchaseGate, type StorePurchaseGateDecision, type StorePurchaseGateRequest, } from './store-commerce-provider.js';
9
- export { defaultStoreSdkErrorCodeMap, normalizeStoreSdkError, type StoreSdk, type StoreSdkCapabilities, type StoreSdkError, type StoreSdkErrorCodeMap, type StoreSdkEvent, type StoreSdkEventName, type StoreSdkEventOf, type StoreSdkFetchProductsInput, type StoreSdkFinishTransactionInput, type StoreSdkFinishTransactionResult, type StoreSdkGetAvailablePurchasesInput, type StoreSdkProduct, type StoreSdkPurchase, type StoreSdkRequestPurchaseInput, type StoreSdkRequestPurchaseResult, } from './store-sdk.js';
10
+ export { defaultStoreSdkErrorCodeMap, normalizeStoreSdkError, type StoreSdk, type StoreSdkCapabilities, type StoreSdkError, type StoreSdkErrorCodeMap, type StoreSdkEvent, type StoreSdkEventName, type StoreSdkEventOf, type StoreSdkFetchProductsInput, type StoreSdkFinishTransactionInput, type StoreSdkGrantedFinishTransactionInput, type StoreSdkRevokedFinishTransactionInput, type StoreSdkFinishTransactionResult, type StoreSdkGetAvailablePurchasesInput, type StoreSdkProduct, type StoreSdkPurchase, type StoreSdkRequestPurchaseInput, type StoreSdkRequestPurchaseResult, } from './store-sdk.js';
10
11
  export { createCapacitorAppEvents, type CapacitorAppEventsApi, type CapacitorIncomingUrlKind, type CapacitorVisibilitySource, } from './app-events.js';
11
12
  export { CapacitorNativeHttpError, createCapacitorNativeJsonTransport, type CapacitorJsonRequest, type CapacitorJsonResponse, type CapacitorNativeHttpErrorCode, type CapacitorNativeJsonTransport, type CreateCapacitorNativeJsonTransportInput, } from './native-http.js';
12
13
  export { createCapacitorViewport, type CapacitorViewportBaseState, type CapacitorOccupiedSurfaceInput, type CapacitorViewportController, type CapacitorViewportHost, } from './viewport.js';
13
14
  /**
14
15
  * Upper bound on how long a `launched` commerce v2 purchase keeps the game
15
16
  * paused when no purchase event for the product arrives (for example a store
16
- * sheet dismissed without a callback).
17
+ * sheet dismissed without a callback). Only for a provider without sheet
18
+ * facts; see `presentedPurchaseActivityTimeoutMs`.
17
19
  */
18
20
  export declare const launchedPurchaseActivityTimeoutMs = 120000;
21
+ /**
22
+ * Safety bound on how long a purchase whose provider reports sheet facts
23
+ * keeps the game paused after the purchase call answered with the sheet
24
+ * still open. The sheet's `closed` fact normally ends the pause; the
25
+ * provider's own `purchasePresentationTimeoutMs` reports one first.
26
+ */
27
+ export declare const presentedPurchaseActivityTimeoutMs: number;
19
28
  export declare function createCapacitorPlatformGateway(input: {
20
29
  readonly target: Extract<PlatformTarget, 'android' | 'ios'>;
21
30
  readonly appVersion: string;
package/dist/index.js CHANGED
@@ -4,12 +4,14 @@ import { PlatformOperationError, } from '@mpgd/platform';
4
4
  import { withShareIntentCheck } from '@mpgd/platform/routes';
5
5
  import { createCapacitorAppEvents, } from './app-events.js';
6
6
  import { namesOtherProtocol } from './bridge-events.js';
7
+ import { purchasePresentationTimeoutMs } from './purchase-presentation.js';
7
8
  import { createBridgeCommerceProvider } from './commerce-bridge.js';
8
9
  import { createCapacitorGatewayCrashReporting, } from './crash-reporting.js';
9
10
  import { assertProviderResponseData, createCapacitorProviderRegistry, } from './providers.js';
10
11
  import { assertNotificationsV2Served, externalActivityMethods } from './request-policy.js';
11
12
  import { createCapacitorViewport } from './viewport.js';
12
13
  export { capacitorCrashlyticsPluginName, capacitorCrashReportingProvider, createCapacitorCrashReporter, createLocalCrashCollectionPreferenceStore, } from './crash-reporting.js';
14
+ export { createNativePurchasePresentation, purchasePresentationTimeoutMs, } from './purchase-presentation.js';
13
15
  export { createCapacitorProviderRegistry, createDegradedCommerceCapabilities, createDegradedNotificationCapabilities, } from './providers.js';
14
16
  export { assertCommerceBridgeResult, commerceV2InvalidPayloadBridgeErrorCode, commerceV2MethodUnsupportedBridgeErrorCode, createBridgeCommerceProvider, createCommerceProviderBridge, createCommerceServiceProvider, } from './commerce-bridge.js';
15
17
  export { accountBindingLookupFailedStoreCode, createStoreCommerceProvider, purchaseGateFailedStoreCode, purchaseGateTimeoutMs, purchasesPausedStoreCode, recoverableErrorCodes, } from './store-commerce-provider.js';
@@ -23,9 +25,17 @@ function isRecord(value) {
23
25
  /**
24
26
  * Upper bound on how long a `launched` commerce v2 purchase keeps the game
25
27
  * paused when no purchase event for the product arrives (for example a store
26
- * sheet dismissed without a callback).
28
+ * sheet dismissed without a callback). Only for a provider without sheet
29
+ * facts; see `presentedPurchaseActivityTimeoutMs`.
27
30
  */
28
31
  export const launchedPurchaseActivityTimeoutMs = 120_000;
32
+ /**
33
+ * Safety bound on how long a purchase whose provider reports sheet facts
34
+ * keeps the game paused after the purchase call answered with the sheet
35
+ * still open. The sheet's `closed` fact normally ends the pause; the
36
+ * provider's own `purchasePresentationTimeoutMs` reports one first.
37
+ */
38
+ export const presentedPurchaseActivityTimeoutMs = purchasePresentationTimeoutMs;
29
39
  const safeFallbackMethods = new Set([
30
40
  'identity.getPlayer',
31
41
  'identity.getSession',
@@ -59,12 +69,14 @@ export function createCapacitorPlatformGateway(input) {
59
69
  // other provider method, so the bridge envelope, response validation, and
60
70
  // external activity bookkeeping are exercised in process exactly as over a
61
71
  // transport.
72
+ const subscribePresentation = commerceProvider?.bridge.subscribePresentation?.bind(commerceProvider.bridge);
62
73
  const commerce = commerceProvider === undefined
63
74
  ? undefined
64
75
  : createBridgeCommerceProvider({
65
76
  store: commerceProvider.commerce.store,
66
77
  // The host below is this package's, on the same contract.
67
78
  acceptsFinishRetry: commerceProvider.commerce.acceptsFinishRetry === true,
79
+ acceptsRevokedFinish: commerceProvider.commerce.acceptsRevokedFinish === true,
68
80
  request: (method, payload) => request(method, payload),
69
81
  // The provider's channel (`NativeBridge`) may carry other protocols.
70
82
  // Only an envelope that names another protocol is dropped; anything
@@ -75,6 +87,10 @@ export function createCapacitorPlatformGateway(input) {
75
87
  listener(envelope);
76
88
  }
77
89
  }),
90
+ // Facts only from a bridge that serves them (`createCommerceServiceProvider`
91
+ // over a provider with `presentation`); otherwise `commerce` has no
92
+ // `presentation` and purchases keep the result-based pause below.
93
+ ...(subscribePresentation === undefined ? {} : { subscribePresentation }),
78
94
  });
79
95
  const notificationsProvider = providers.notifications;
80
96
  // Served through the same request path as every other provider method, as
@@ -137,6 +153,22 @@ export function createCapacitorPlatformGateway(input) {
137
153
  ? lifecycle.beginExternalActivity?.()
138
154
  : undefined;
139
155
  let holdActivity = false;
156
+ // Watched before the request is sent: a provider reports a sheet that
157
+ // never opened before it answers.
158
+ const presentation = releaseExternalActivity !== undefined
159
+ && method === 'commerce.v2.requestPurchase'
160
+ ? watchPurchasePresentation(payload)
161
+ : undefined;
162
+ // Released once: by the sheet's own end (which may come while the call
163
+ // still runs, for example a safety-timeout `closed`), or after the call.
164
+ let released = false;
165
+ const release = () => {
166
+ if (!released) {
167
+ released = true;
168
+ releaseExternalActivity?.();
169
+ }
170
+ };
171
+ presentation?.onEnd(release);
140
172
  try {
141
173
  const provider = leaderboardRoute === 'remote'
142
174
  && (method === 'leaderboard.submitScore' || method === 'leaderboard.open')
@@ -148,11 +180,15 @@ export function createCapacitorPlatformGateway(input) {
148
180
  const data = await sendRequest(method, payload, provider);
149
181
  if (releaseExternalActivity !== undefined
150
182
  && method === 'commerce.v2.requestPurchase'
151
- && isRecord(data) && data.outcome === 'launched') {
152
- // The store UI is still open: stay paused until this product's
153
- // purchase event arrives or the safety timeout ends.
183
+ && isRecord(data) && data.outcome === 'launched'
184
+ && presentation?.reported() !== true) {
185
+ // No sheet facts for this purchase: the store UI may still be
186
+ // open, so stay paused until this product's purchase event
187
+ // arrives or the safety timeout ends. With facts, the sheet's
188
+ // own `closed` ends the pause (below), and a pending purchase
189
+ // whose sheet closed holds nothing.
154
190
  holdActivity = true;
155
- holdLaunchedPurchaseActivity(releaseExternalActivity, isRecord(payload) ? payload.logicalId : undefined);
191
+ holdLaunchedPurchaseActivity(release, isRecord(payload) ? payload.logicalId : undefined);
156
192
  }
157
193
  return data;
158
194
  }
@@ -168,10 +204,106 @@ export function createCapacitorPlatformGateway(input) {
168
204
  return await sendRequest(method, payload, undefined);
169
205
  }
170
206
  finally {
171
- if (!holdActivity) {
172
- releaseExternalActivity?.();
207
+ if (holdActivity) {
208
+ presentation?.dispose();
209
+ }
210
+ else if (!released && presentation?.open() === true) {
211
+ // The sheet is still up, whatever the answer (a result, a pending
212
+ // purchase, or a throw): stay paused until its `closed` fact.
213
+ holdPresentedPurchaseActivity(release, presentation);
214
+ }
215
+ else {
216
+ presentation?.dispose();
217
+ release();
218
+ }
219
+ }
220
+ }
221
+ /**
222
+ * Follows one purchase's presentation facts (by its idempotency key) from
223
+ * `commerce.presentation`: the bridge's facts already validated, with a
224
+ * superseded or replayed one dropped, so a stale fact cannot end the hold
225
+ * while the sheet is up. Undefined when the bridge serves no facts or the
226
+ * payload has no key.
227
+ */
228
+ function watchPurchasePresentation(payload) {
229
+ const idempotencyKey = isRecord(payload) ? payload.idempotencyKey : undefined;
230
+ const facts = commerce?.presentation;
231
+ if (facts === undefined || typeof idempotencyKey !== 'string') {
232
+ return undefined;
233
+ }
234
+ // `held`: the sheet is (`open`) or may be (`unknown`) up; `opened`: seen open.
235
+ let held = false;
236
+ let opened = false;
237
+ let ended = false;
238
+ let onEnd;
239
+ let unsubscribe;
240
+ let disposed = false;
241
+ const dispose = () => {
242
+ disposed = true;
243
+ onEnd = undefined;
244
+ unsubscribe?.();
245
+ unsubscribe = undefined;
246
+ };
247
+ const subscription = facts.subscribe((event) => {
248
+ if (event.idempotencyKey !== idempotencyKey || ended) {
249
+ return;
250
+ }
251
+ const { state } = event;
252
+ if (state === 'open' || state === 'unknown') {
253
+ // `unknown`: the sheet may still be up; hold as for an open one.
254
+ held = true;
255
+ opened ||= state === 'open';
173
256
  }
257
+ else if (state === 'closed' || (state === 'not-started' && !opened)) {
258
+ // A sheet seen open ends only with `closed` (its provider turns a
259
+ // later `not-started` into `closed`); a `not-started` then belongs
260
+ // to another call of the same key.
261
+ ended = true;
262
+ const end = onEnd;
263
+ dispose();
264
+ end?.();
265
+ }
266
+ });
267
+ if (disposed) {
268
+ subscription();
269
+ }
270
+ else {
271
+ unsubscribe = subscription;
174
272
  }
273
+ return {
274
+ reported: () => held || ended,
275
+ open: () => held && !ended,
276
+ onEnd(callback) {
277
+ if (ended) {
278
+ callback();
279
+ }
280
+ else {
281
+ onEnd = callback;
282
+ }
283
+ },
284
+ dispose,
285
+ };
286
+ }
287
+ /**
288
+ * Keeps a purchase's external activity open while its store sheet is up:
289
+ * until the sheet's `closed` fact, the safety timeout, or dispose. A
290
+ * result, a pending purchase, or a purchase event does not end it.
291
+ */
292
+ function holdPresentedPurchaseActivity(release, presentation) {
293
+ let done = false;
294
+ const finish = () => {
295
+ if (done) {
296
+ return;
297
+ }
298
+ done = true;
299
+ launchedPurchaseHolds.delete(finish);
300
+ clearTimeout(timer);
301
+ presentation.dispose();
302
+ release();
303
+ };
304
+ launchedPurchaseHolds.add(finish);
305
+ const timer = setTimeout(finish, presentedPurchaseActivityTimeoutMs);
306
+ presentation.onEnd(finish);
175
307
  }
176
308
  /**
177
309
  * Keeps the external activity of a `launched` purchase open until the
@@ -1,4 +1,4 @@
1
- import { type BridgeEvent, type BridgeMethod, type BridgeNotificationsEvent, type BridgeNotificationsV2Method, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
1
+ import { type BridgeCommercePresentationEvent, type BridgeEvent, type BridgeMethod, type BridgeNotificationsEvent, type BridgeNotificationsV2Method, type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
2
  import type { AdProvider } from '@mpgd/platform/ads';
3
3
  import { type CommerceCapabilities, type NotificationSubscriptionAdapter, type PlatformProviderAvailability, type PlatformProviderFeature } from '@mpgd/platform';
4
4
  import { type CommerceProvider } from '@mpgd/platform/commerce';
@@ -86,7 +86,15 @@ export interface CapacitorNotificationServiceProvider extends CapacitorServicePr
86
86
  /** The provider the registry serves commerce v2 from. */
87
87
  export interface CapacitorCommerceServiceProvider extends CapacitorServiceProvider {
88
88
  readonly commerce: CommerceProvider;
89
- readonly bridge: NativeBridge & Required<Pick<NativeBridge, 'subscribe'>>;
89
+ readonly bridge: NativeBridge & Required<Pick<NativeBridge, 'subscribe'>> & {
90
+ /**
91
+ * The provider's purchase presentation facts
92
+ * (`CommerceProviderBridge.subscribePresentation`). Only with it does
93
+ * `gateway.commerce` offer `presentation`, and the gateway keep a
94
+ * purchase's pause until the sheet's `closed` fact.
95
+ */
96
+ subscribePresentation?(listener: (event: BridgeCommercePresentationEvent) => void): () => void;
97
+ };
90
98
  }
91
99
  export interface CapacitorProviderCapabilitySnapshot {
92
100
  readonly availability: Readonly<Record<PlatformProviderFeature, PlatformProviderAvailability>>;
@@ -0,0 +1,55 @@
1
+ import type { PluginListenerHandle } from '@capacitor/core';
2
+ import type { StoreSdkPurchasePresentation } from './store-sdk.js';
3
+ /**
4
+ * Upper bound on how long one store sheet counts as open (or possibly open)
5
+ * without a native `closed` fact. Past it the provider reports `closed` with
6
+ * reason `timeout`, so a lost native observation can pause the game, but
7
+ * never forever. It matches the purchase flow timeout of the OpenIAP Google
8
+ * core (15 minutes).
9
+ */
10
+ export declare const purchasePresentationTimeoutMs: number;
11
+ /** A store plugin's native surface for sheet facts (Play Billing, StoreKit). */
12
+ export interface NativePurchasePresentationPlugin {
13
+ addListener(eventName: 'purchasePresentation', listener: (event: unknown) => void): Promise<PluginListenerHandle>;
14
+ readonly getPresentationSupport?: () => Promise<unknown>;
15
+ }
16
+ /** What a store plugin's `StoreSdk` uses to report its native sheet facts. */
17
+ export interface NativePurchasePresentation {
18
+ /** The SDK's `purchasePresentation` declaration. */
19
+ readonly purchasePresentation: StoreSdkPurchasePresentation;
20
+ /**
21
+ * Whether the native code in the app reports facts (`getPresentationSupport`).
22
+ * A build from before them answers `UNIMPLEMENTED`, which is remembered;
23
+ * any other failure is asked again next time.
24
+ */
25
+ supported(): Promise<boolean>;
26
+ /**
27
+ * Someone follows the facts: an adapter that reads them subscribes when it
28
+ * builds its provider (one from before them never does).
29
+ */
30
+ followed(): boolean;
31
+ /** Follows facts for a `purchase` call about to be sent with this id. */
32
+ begin(presentationId: string): void;
33
+ /**
34
+ * Applies the sheet state a `purchase` answer, or a rejection's `data`,
35
+ * carried (`presentation`): `open`, `closed`, `not-started`, or
36
+ * `requested` (native code still runs the flow and has observed nothing
37
+ * yet; its facts follow as events).
38
+ */
39
+ answered(presentationId: string, answerOrError: unknown): void;
40
+ }
41
+ /**
42
+ * Sheet facts by presentation id from the plugin's `purchasePresentation`
43
+ * events and the `presentation` field of its `purchase` answers, whichever
44
+ * arrives first; each call gets at most one `open` and one terminal fact.
45
+ * A `requested` answer is reported as `unknown`: the sheet may still come,
46
+ * so the provider keeps the call waiting for the native `open`, `closed`, or
47
+ * `not-started` instead of reading the answer as a sheet that never opened
48
+ * or inventing one that did. The native listener is attached with the first
49
+ * subscriber and removed with the last.
50
+ *
51
+ * Exported for the store plugins, which peer on this package. A plugin reads
52
+ * it through the module namespace, so with an adapter from before it the
53
+ * plugin declares no facts (and that adapter never asks for them).
54
+ */
55
+ export declare function createNativePurchasePresentation(native: NativePurchasePresentationPlugin): NativePurchasePresentation;