@mpgd/adapter-capacitor 0.4.12 → 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 ADDED
@@ -0,0 +1,318 @@
1
+ # @mpgd/adapter-capacitor
2
+
3
+ The base adapter uses `@mpgd/capacitor-game-services` for bounded local JSON
4
+ storage and a small fail-closed bridge. It does **not** install a store, ad,
5
+ identity, leaderboard, or push SDK. Pass separately installed modules through
6
+ `createCapacitorPlatformGateway({ providers })`; each module declares its
7
+ owned bridge methods, provider features, and current availability. Duplicate
8
+ method/feature registrations and attempts to replace base storage are errors.
9
+
10
+ ```ts
11
+ import {
12
+ createCapacitorPlatformGateway,
13
+ type CapacitorServiceProvider,
14
+ } from '@mpgd/adapter-capacitor';
15
+
16
+ export function createGameGateway(providers: readonly CapacitorServiceProvider[]) {
17
+ return createCapacitorPlatformGateway({
18
+ target: 'android',
19
+ appVersion: '1.0.0',
20
+ buildId: 'game-build',
21
+ providers,
22
+ classifyIncomingUrl(url) {
23
+ const incoming = new URL(url);
24
+ // Replace this example scheme with one registered by your native host.
25
+ if (incoming.protocol !== 'mygame:') return null;
26
+ if (incoming.hostname === 'oauth') return 'oauth';
27
+ if (incoming.hostname === 'game') return 'game';
28
+ return null;
29
+ },
30
+ });
31
+ }
32
+ ```
33
+
34
+ `getCapabilities()` reports a fresh boolean snapshot and optional
35
+ `providerAvailability` detail. The possible states are `unsupported`,
36
+ `configuration-required`, `action-required`, `temporarily-unavailable`, and
37
+ `available`. A target's `features` configuration is an upper bound, **not**
38
+ proof that a provider is installed or ready. `nativeIap` describes one-time
39
+ purchases; `subscriptionIap` is separate. Rewarded, interstitial, and banner
40
+ ads and native versus remote leaderboards remain distinct. Target-config
41
+ applies the configured upper bound to this live provider state.
42
+
43
+ When native and remote leaderboard routes coexist, the target-config wrapper
44
+ selects a `route` on score/open calls; direct adapter callers can request
45
+ `route: 'remote'` to use the base remote bridge instead of the native provider.
46
+ When one ads provider handles multiple formats, pass `format` to `ads.preload`
47
+ so its readiness check cannot use an available format for an unavailable one.
48
+
49
+ Provider initialization failures and availability reads stalled beyond three
50
+ seconds leave the base bridge and local guest boot available. A registered
51
+ provider never silently falls back for purchase, ad, or native leaderboard
52
+ operations; an explicit remote leaderboard route is a separate path. Errors
53
+ retain a stable code and retry hint. The
54
+ provider bridge must return a method-shaped response. In particular, an ad
55
+ `rewardGranted: true` result requires a backend ledger entry. The AdMob SDK
56
+ provider always returns `rewardGranted: false`, even after the SDK fires a
57
+ reward callback. Its explicitly non-authoritative evidence envelope only lets
58
+ the game-services client request a grant; the server must independently verify
59
+ a signed SSV callback before writing its ledger. A native callback alone is
60
+ never a grant. Game-specific product, consent, entitlement, and
61
+ identity policy belongs to the consuming game and its backend.
62
+
63
+ The source tests and installed-tarball consumer validate composition, types,
64
+ error paths, and fallback behavior. They do not establish that any optional
65
+ SDK works on a physical device or that a store/ad setup is release-ready.
66
+
67
+ ## App Store server-side transaction signatures
68
+
69
+ For an iOS game backend, install the optional
70
+ `@apple/app-store-server-library@3.1.0` peer and import
71
+ `createAppleSignedTransactionVerifier` from
72
+ `@mpgd/adapter-capacitor/app-store-server`. Supply DER-encoded Apple root
73
+ certificates from the [Apple PKI](https://www.apple.com/certificateauthority/),
74
+ the game's bundle ID, environment, and production App Apple ID. Use the
75
+ result as `signedTransactionVerifier` in
76
+ `createAppStoreGameServicesEvidenceVerifier`. Keep App Store API credentials,
77
+ trust roots, and this verifier on the backend, not in the Capacitor game.
78
+
79
+ The server fetches the transaction from Apple's API, verifies its JWS, checks
80
+ the product, bundle, environment, account token and transaction ID, then
81
+ writes the game-services grant. A StoreKit device callback is only provisional
82
+ evidence; it never grants a product. Retryable certificate-status failures
83
+ remain pending, while invalid signatures are rejected. Mock verification does
84
+ not prove a real App Store purchase or device delivery.
85
+
86
+ ## Opt-in AdMob rewarded ads
87
+
88
+ Install `@capacitor-community/admob@8.1.0` in the game-owned Capacitor project,
89
+ configure the distinct AdMob **application ID** in AndroidManifest.xml and
90
+ Info.plist, and run `cap sync`. Use the [plugin installation and native setup
91
+ guide](https://github.com/capacitor-community/admob#installation) for the exact
92
+ Android and iOS keys, SKAdNetwork list, and tracking disclosure. The optional
93
+ `@mpgd/adapter-capacitor/admob` subpath does not load from the base adapter;
94
+ games that do not install AdMob retain their existing SDK-free gateway.
95
+ The community plugin currently targets Google Mobile Ads Android 25.4.x and
96
+ iOS 13.6.0. Its Android default uses a dynamic patch selector; set
97
+ `playServicesAdsVersion = '25.4.0'` in the game-owned `variables.gradle` to
98
+ keep release builds reproducible until the plugin adopts a newer tested SDK.
99
+
100
+ ```ts
101
+ import { createCapacitorPlatformGateway } from '@mpgd/adapter-capacitor';
102
+ import { createCapacitorAdMobRewardedProvider } from '@mpgd/adapter-capacitor/admob';
103
+
104
+ const admob = createCapacitorAdMobRewardedProvider({
105
+ adUnits: { CONTINUE_AFTER_FAIL: 'ca-app-pub-1234567890123456/1234567890' },
106
+ getPlayerId: () => authenticatedPlayerId,
107
+ });
108
+ const gateway = createCapacitorPlatformGateway({
109
+ target: 'android', appVersion: '1.0.0', buildId: 'game-build',
110
+ providers: [admob],
111
+ });
112
+ const consentAllowsAds = await admob.requestConsent();
113
+ if (!consentAllowsAds) {
114
+ // Keep the ad action disabled; never bypass the privacy choice.
115
+ }
116
+ ```
117
+
118
+ Use target-specific ad unit IDs for Android and iOS; the logical placement
119
+ ID must match the game catalog and backend config. `getPlayerId()` must return
120
+ the same authenticated player ID as `GameServicesClient`, not a device ID or
121
+ an unverified local guest label. Re-check consent when the game resumes or
122
+ the privacy choice changes, and expose `admob.showPrivacyOptions()` from game
123
+ settings where required. `getCapabilities()` reports `action-required` until
124
+ consent is ready and `temporarily-unavailable` during a show. A timed-out
125
+ native load or presentation reports non-retryable `action-required` and
126
+ quarantines that SDK instance to avoid overlapping shows
127
+ while its state is unknown. Recreating the Kit provider with the same SDK does
128
+ not clear the quarantine; restart the native app before trying again. Do not
129
+ show rewarded ads through another direct SDK caller concurrently: the plugin's
130
+ reward and dismissal events are global, not tagged with a Kit operation ID.
131
+
132
+ `gateway.ads.preload()` validates the logical placement but deliberately does
133
+ not load an ad: this SDK attaches SSV data at load time, whereas the Kit's
134
+ operation ID exists only at show time. `gateway.ads.showRewarded()` therefore
135
+ loads a fresh ad with SSV `userId` and encoded player/placement/operation
136
+ binding, waits for native dismissal, then returns SDK reward evidence. Call
137
+ `GameServicesClient.claimRewardedAd()` rather than award currency from that
138
+ result; `rewardGranted` remains `false` in that provisional platform result.
139
+ If dismissal arrives without an observed reward, the provider returns
140
+ `pending` with the same non-authoritative binding. The backend can then look
141
+ for a later signed SSV callback without replaying the ad UI; a genuinely
142
+ skipped ad may remain pending until game-owned support or expiry policy
143
+ settles it. Mediation and asynchronous bridge delivery have no safe universal
144
+ 250 ms reward-event cutoff.
145
+ The receiver and ledger described in [AdMob SSV](../../docs/ADMOB_SSV.md)
146
+ remain mandatory. A late SSV callback can leave the claim pending for
147
+ reconciliation. `isTesting: true` is for SDK test ads only; [the plugin notes
148
+ that test ads do not invoke the SSV endpoint](https://github.com/capacitor-community/admob/blob/main/docs/rewarded.md#server-side-verification),
149
+ so they cannot prove a production grant.
150
+
151
+ The Kit tests cover consent, per-operation binding, reward versus dismissal,
152
+ and bridge contracts with an injected SDK. They are not evidence of a real
153
+ AdMob account, live callback delivery, native device behavior, or store policy
154
+ approval.
155
+
156
+ `gateway.secureCredentials` uses dedicated native Keychain/Keystore methods for
157
+ opaque session credentials. Its absence or a native error must not be hidden by
158
+ writing the secret into `gateway.storage`. Keys are bounded, values are
159
+ size-limited, and malformed bridge responses fail closed. This is a storage
160
+ boundary, not proof of server authentication: an installation ID or local
161
+ `playerId` is never a server principal by itself.
162
+
163
+ ## App lifecycle and native entry
164
+
165
+ The adapter uses the official Capacitor App plugin for foreground state,
166
+ Android back navigation, and incoming URLs. Install `@capacitor/app` in the
167
+ native shell and run `cap sync` so the native plugin is present. The reference
168
+ shell and generated game template include it. A `classifyIncomingUrl` callback
169
+ must explicitly return `game` or `oauth`; unknown URLs are discarded rather
170
+ than being delivered to gameplay. Register your URL scheme or App/Universal
171
+ Links in the native host separately. See the [Capacitor App API](https://capacitorjs.com/docs/apis/app).
172
+
173
+ `lifecycle.onPause` and `onResume` deduplicate App and web visibility events.
174
+ Purchases, ads, and identity upgrades keep execution paused until their
175
+ provider operation settles. Use `beginExternalActivity` for other external UI.
176
+ On Android, a back handler returns `true` if the game consumed the event;
177
+ otherwise the adapter navigates browser history when possible or exits the
178
+ app. Cold URLs are read through `getInitialGameUrl` or
179
+ `getInitialOAuthRedirect`; warm URLs use separate `onGameUrlOpen` and
180
+ `onOAuthRedirect` callbacks. OAuth responses never enter the game-link
181
+ callback. Call `lifecycle.dispose()` when the host tears down this gateway;
182
+ the adapter removes only its own listener handles, never every App listener.
183
+
184
+ Save progress at checkpoints and transaction boundaries. A pause callback can
185
+ request a best-effort save, but neither pause nor back is guaranteed to run
186
+ before the operating system terminates a process. The lifecycle tests use an
187
+ injected App API and do not claim device-level lifecycle verification.
188
+
189
+ For example, let the game's checkpoint flow own durability; the native pause
190
+ event is only an additional opportunity to flush the latest checkpoint:
191
+
192
+ ```ts
193
+ import type { PlatformGateway } from '@mpgd/platform';
194
+
195
+ export function attachCheckpointSaving(
196
+ gateway: PlatformGateway,
197
+ saveCheckpoint: () => Promise<void>,
198
+ ): () => void {
199
+ return gateway.lifecycle.onPause(() => {
200
+ void saveCheckpoint().catch((error: unknown) => {
201
+ console.error('Best-effort pause save failed.', error);
202
+ });
203
+ });
204
+ }
205
+
206
+ // The game also awaits saveCheckpoint() at level and transaction boundaries;
207
+ // it must never defer its only save until a close, back, or pause event.
208
+ ```
209
+
210
+ ## Scoped native JSON HTTP
211
+
212
+ `createCapacitorNativeJsonTransport` uses the explicit `CapacitorHttp.request`
213
+ helper from `@capacitor/core`; it does not enable global `fetch` or XHR patching.
214
+ Use it with the HTTP JSON path of `createGameServicesRuntime`, not the oRPC
215
+ path. The game imports only public kit APIs:
216
+
217
+ ```ts
218
+ import { createCapacitorNativeJsonTransport } from '@mpgd/adapter-capacitor';
219
+ import { createGameServicesRuntime } from '@mpgd/game-services/runtime';
220
+
221
+ const baseUrl = 'https://api.example.com';
222
+ const httpTransport = createCapacitorNativeJsonTransport({
223
+ target: 'android',
224
+ baseUrl,
225
+ allowedOrigin: 'https://api.example.com',
226
+ });
227
+ const runtime = createGameServicesRuntime({
228
+ gateway,
229
+ playerId,
230
+ authorityMode: 'production',
231
+ baseUrl,
232
+ transport: 'http',
233
+ httpTransport,
234
+ getHeaders: () => ({ authorization: `Bearer ${currentAccessToken()}` }),
235
+ });
236
+ ```
237
+
238
+ The game supplies its real gateway, player ID, and token resolver. The native
239
+ transport permits only its configured HTTPS origin and relative JSON API paths;
240
+ it rejects 3xx responses and changed routes while tolerating equivalent native
241
+ URL encoding of the same path or query. It requests
242
+ `disableRedirects: true` from Capacitor. A native implementation that ignored
243
+ that option could have followed a redirect before JS sees the result, so
244
+ redirect and credential handling still require native integration testing.
245
+ The transport owns `Accept: application/json` and POST `Content-Type`; callers
246
+ supplying either header are rejected instead of having it silently rewritten.
247
+ It supports GET and POST JSON only, not streaming, file uploads, or arbitrary
248
+ Fetch semantics. `AbortSignal` and the overall timeout stop the
249
+ JS wait; they do **not** prove the native request or server operation was
250
+ canceled. Reconcile purchases and reward claims before retrying them.
251
+
252
+ Request size is checked before calling the native bridge. The response size
253
+ limit is checked **after** Capacitor returns data to JS; it is not a native
254
+ receive-memory cap. The injected Http tests and platform staging builds cover
255
+ the contract, not real network behavior on both physical OSes. See the
256
+ [Capacitor HTTP API](https://capacitorjs.com/docs/apis/http) for the native
257
+ helper and its redirect/timeout options.
258
+
259
+ ## Usable viewport and native occupied surfaces
260
+
261
+ `gateway.viewport` reports the full WebView size and separate safe-area,
262
+ system-bar, keyboard, and named occupied-surface measurements in **CSS pixels**.
263
+ Pass the state to `resolveTargetViewportUsableArea` from `@mpgd/target-config`;
264
+ it takes the furthest intrusion per edge rather than summing overlapping
265
+ safe-area, system-bar, keyboard, and banner values. The default host reads the
266
+ starter's `--mpgd-safe-area-*` CSS variables, Capacitor SystemBars'
267
+ `--safe-area-inset-*` fallback, and `visualViewport` changes. On Android,
268
+ SystemBars injects fallback CSS variables for older WebViews whose CSS `env`
269
+ safe-area values are incorrect. The injected `--safe-area-inset-*` values must
270
+ be valid CSS lengths on `documentElement` (`:root`); putting them only on
271
+ `body` or a nested container does not update the root aliases. See the
272
+ [SystemBars API](https://capacitorjs.com/docs/apis/system-bars).
273
+ Keyboard behavior also depends on the native [Keyboard resize mode](https://capacitorjs.com/docs/apis/keyboard),
274
+ so inspect the actual device layout before promising a particular inset.
275
+
276
+ Choose **one layout owner**. The starter's existing CSS padding already
277
+ reserves safe-area space; do not apply `usableArea.contentBounds` to that
278
+ already-padded `#game` element. For a JS-owned full-viewport canvas and DOM
279
+ overlay, remove the host's safe-area padding and apply the same rectangle to
280
+ both elements:
281
+
282
+ ```ts
283
+ import { resolveTargetViewportUsableArea } from '@mpgd/target-config';
284
+ import type { PlatformGateway } from '@mpgd/platform';
285
+
286
+ export function mountUsableViewport(
287
+ gateway: PlatformGateway,
288
+ canvasHost: HTMLElement,
289
+ overlayHost: HTMLElement,
290
+ ): () => void {
291
+ const viewport = gateway.viewport;
292
+ if (viewport === undefined) return () => undefined;
293
+ const apply = () => {
294
+ const state = viewport.getState();
295
+ const bounds = resolveTargetViewportUsableArea(state, state).contentBounds;
296
+ for (const element of [canvasHost, overlayHost]) {
297
+ element.style.position = 'absolute';
298
+ element.style.left = `${bounds.x}px`;
299
+ element.style.top = `${bounds.y}px`;
300
+ element.style.width = `${bounds.width}px`;
301
+ element.style.height = `${bounds.height}px`;
302
+ }
303
+ };
304
+ apply();
305
+ return viewport.onChange(apply);
306
+ }
307
+
308
+ // Call the returned unsubscribe during game teardown. lifecycle.dispose()
309
+ // tears down the gateway-owned native viewport listeners.
310
+ ```
311
+
312
+ Future banner providers can call `createCapacitorViewport()` and pass that
313
+ controller to `createCapacitorPlatformGateway({ viewport })`, then report
314
+ actual `surfaceId` bounds with `setOccupiedSurface`. For physical-pixel
315
+ measurements, the provider supplies `pixelsPerCssPixel`; the game never guesses
316
+ device pixel ratio. A caller-injected controller remains caller-owned and must
317
+ be disposed separately. Tests cover layout, rotation, multiple surfaces, and
318
+ unsubscribe, but do not establish physical-device inset or keyboard accuracy.
@@ -0,0 +1,26 @@
1
+ import { type AdMobPlugin } from '@capacitor-community/admob';
2
+ import type { CapacitorServiceProvider } from './providers.js';
3
+ /** Only the AdMob subpath imports the optional native SDK. */
4
+ export type RewardedAdMobSdk = Pick<AdMobPlugin, 'initialize' | 'requestConsentInfo' | 'showConsentForm' | 'showPrivacyOptionsForm' | 'prepareRewardVideoAd' | 'showRewardVideoAd' | 'addListener'>;
5
+ export interface CreateCapacitorAdMobRewardedProviderInput {
6
+ /** Logical placement IDs mapped to target-specific, game-owned AdMob ad units. */
7
+ readonly adUnits: Readonly<Record<string, string>>;
8
+ /** Must resolve to the same authenticated player ID used by GameServicesClient. */
9
+ readonly getPlayerId: () => Promise<string> | string;
10
+ /** Test ads do not send Google SSV callbacks; server grants remain pending. */
11
+ readonly isTesting?: boolean;
12
+ readonly sdk?: RewardedAdMobSdk;
13
+ readonly showTimeoutMs?: number;
14
+ }
15
+ export interface CapacitorAdMobRewardedProvider extends CapacitorServiceProvider {
16
+ /** Present UMP when required. Call before the gateway advertises ad readiness. */
17
+ requestConsent(): Promise<boolean>;
18
+ /** Game settings should expose this when the privacy message requires it. */
19
+ showPrivacyOptions(): Promise<void>;
20
+ }
21
+ /**
22
+ * Opt-in rewarded-only provider. The SDK's SSV options are attached when an
23
+ * operation is shown, never to an earlier unbound preload. A local reward
24
+ * callback is only a signal to ask the backend; it is not a ledger grant.
25
+ */
26
+ export declare function createCapacitorAdMobRewardedProvider(input: CreateCapacitorAdMobRewardedProviderInput): CapacitorAdMobRewardedProvider;
package/dist/admob.js ADDED
@@ -0,0 +1,288 @@
1
+ import { AdMob, AdmobConsentStatus, RewardAdPluginEvents, } from '@capacitor-community/admob';
2
+ import { admobClientRewardEvidenceSchema } from '@mpgd/game-services/admob-client-reward';
3
+ import { admobSsvMaximumBindingFieldLength, encodeAdMobSsvCustomData, } from '@mpgd/game-services/admob-ssv';
4
+ const unitPattern = /^ca-app-pub-\d+\/\d+$/u;
5
+ const defaultShowTimeoutMs = 180_000;
6
+ const loadTimeoutMs = 30_000;
7
+ const preflightTimeoutMs = 10_000;
8
+ const listenerCleanupTimeoutMs = 1_000;
9
+ const maximumCustomDataBytes = 1_024;
10
+ // The native plugin's rewarded event stream and prepared-ad table are global.
11
+ // Coordinate Kit providers sharing one SDK instance, even across gateways.
12
+ const activeSdk = new WeakSet();
13
+ const uncertainSdk = new WeakSet();
14
+ function response(input, result) {
15
+ return { id: input.id, ok: true, data: result };
16
+ }
17
+ function failure(input, code, retryable = false) {
18
+ return { id: input.id, ok: false, error: { code, message: code, retryable } };
19
+ }
20
+ function isRecord(value) {
21
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
22
+ }
23
+ function isRewardItem(value) {
24
+ return isRecord(value)
25
+ && typeof value.type === 'string'
26
+ && value.type.length > 0
27
+ && typeof value.amount === 'number'
28
+ && Number.isFinite(value.amount)
29
+ && value.amount > 0;
30
+ }
31
+ class PreflightTimeoutError extends Error {
32
+ }
33
+ async function withTimeout(promise, milliseconds, onLate) {
34
+ let timer;
35
+ let expired = false;
36
+ const watched = promise.then((value) => {
37
+ if (expired && onLate !== undefined) {
38
+ void Promise.resolve().then(() => onLate(value)).catch(() => undefined);
39
+ }
40
+ return value;
41
+ });
42
+ try {
43
+ return await Promise.race([
44
+ watched,
45
+ new Promise((_resolve, reject) => {
46
+ timer = setTimeout(() => {
47
+ expired = true;
48
+ reject(new PreflightTimeoutError('AdMob preflight timed out.'));
49
+ }, milliseconds);
50
+ }),
51
+ ]);
52
+ }
53
+ finally {
54
+ if (timer !== undefined) {
55
+ clearTimeout(timer);
56
+ }
57
+ }
58
+ }
59
+ /**
60
+ * Opt-in rewarded-only provider. The SDK's SSV options are attached when an
61
+ * operation is shown, never to an earlier unbound preload. A local reward
62
+ * callback is only a signal to ask the backend; it is not a ledger grant.
63
+ */
64
+ export function createCapacitorAdMobRewardedProvider(input) {
65
+ const sdk = input.sdk ?? AdMob;
66
+ const showTimeoutMs = input.showTimeoutMs ?? defaultShowTimeoutMs;
67
+ if (!Number.isSafeInteger(showTimeoutMs) || showTimeoutMs < 1_000 || showTimeoutMs > 300_000) {
68
+ throw new Error('AdMob show timeout must be between 1 and 300 seconds.');
69
+ }
70
+ const adUnits = new Map();
71
+ for (const [placementId, unit] of Object.entries(input.adUnits)) {
72
+ if (placementId.trim() === '' || !unitPattern.test(unit)) {
73
+ throw new Error('AdMob rewarded placements require non-empty IDs and production-format ad units.');
74
+ }
75
+ adUnits.set(placementId, unit);
76
+ }
77
+ if (adUnits.size === 0) {
78
+ throw new Error('AdMob rewarded provider requires at least one ad unit.');
79
+ }
80
+ let initialized;
81
+ let consentTask;
82
+ let canRequestAds = false;
83
+ let privacyOptionsRequired = false;
84
+ const initialize = async () => {
85
+ if (initialized === undefined) {
86
+ initialized = sdk.initialize().catch((error) => {
87
+ initialized = undefined;
88
+ throw error;
89
+ });
90
+ }
91
+ await initialized;
92
+ };
93
+ const requestConsent = () => {
94
+ if (consentTask === undefined) {
95
+ consentTask = (async () => {
96
+ canRequestAds = false;
97
+ await initialize();
98
+ let info = await sdk.requestConsentInfo();
99
+ if (info.status === AdmobConsentStatus.REQUIRED && info.isConsentFormAvailable) {
100
+ info = await sdk.showConsentForm();
101
+ }
102
+ canRequestAds = info.canRequestAds === true;
103
+ privacyOptionsRequired = info.privacyOptionsRequirementStatus === 'REQUIRED';
104
+ return canRequestAds;
105
+ })().finally(() => { consentTask = undefined; });
106
+ }
107
+ return consentTask;
108
+ };
109
+ const showRewarded = async (placementId, idempotencyKey) => {
110
+ const adId = adUnits.get(placementId);
111
+ if (adId === undefined || !canRequestAds || activeSdk.has(sdk) || uncertainSdk.has(sdk)) {
112
+ return { status: 'unavailable', rewardGranted: false };
113
+ }
114
+ if (idempotencyKey.trim() === ''
115
+ || idempotencyKey.length > admobSsvMaximumBindingFieldLength
116
+ || placementId.length > admobSsvMaximumBindingFieldLength) {
117
+ return { status: 'failed', rewardGranted: false };
118
+ }
119
+ activeSdk.add(sdk);
120
+ const handles = [];
121
+ let rewardEarned = false;
122
+ let timer;
123
+ let loadTimer;
124
+ let loadTimedOut = false;
125
+ let nativePreflightStarted = false;
126
+ try {
127
+ const playerId = await withTimeout(Promise.resolve().then(() => input.getPlayerId()), preflightTimeoutMs);
128
+ if (typeof playerId !== 'string' || playerId.trim() === ''
129
+ || playerId.length > admobSsvMaximumBindingFieldLength) {
130
+ return { status: 'failed', rewardGranted: false };
131
+ }
132
+ const customData = encodeAdMobSsvCustomData({ playerId, placementId, idempotencyKey });
133
+ if (new TextEncoder().encode(customData).byteLength > maximumCustomDataBytes) {
134
+ return { status: 'failed', rewardGranted: false };
135
+ }
136
+ // This plugin resolves showRewardVideoAd on reward, but on Android a
137
+ // dismissal without reward leaves that promise unresolved. Observe the
138
+ // terminal native events separately, and hold lifecycle through close.
139
+ let finish;
140
+ const terminal = new Promise((resolve) => {
141
+ finish = resolve;
142
+ });
143
+ nativePreflightStarted = true;
144
+ handles.push(await withTimeout(sdk.addListener(RewardAdPluginEvents.Rewarded, (reward) => {
145
+ if (isRewardItem(reward)) {
146
+ rewardEarned = true;
147
+ }
148
+ }), preflightTimeoutMs, (handle) => handle.remove()));
149
+ handles.push(await withTimeout(sdk.addListener(RewardAdPluginEvents.Dismissed, () => {
150
+ // Let a same-turn Rewarded event settle before reading the flag.
151
+ queueMicrotask(() => finish('dismissed'));
152
+ }), preflightTimeoutMs, (handle) => handle.remove()));
153
+ handles.push(await withTimeout(sdk.addListener(RewardAdPluginEvents.FailedToShow, () => {
154
+ finish('failed');
155
+ }), preflightTimeoutMs, (handle) => handle.remove()));
156
+ const loaded = await Promise.race([
157
+ sdk.prepareRewardVideoAd({
158
+ adId,
159
+ ...(input.isTesting === undefined ? {} : { isTesting: input.isTesting }),
160
+ ssv: { userId: playerId, customData },
161
+ }),
162
+ new Promise((_resolve, reject) => {
163
+ loadTimer = setTimeout(() => {
164
+ loadTimedOut = true;
165
+ reject(new Error('AdMob load timed out.'));
166
+ }, loadTimeoutMs);
167
+ }),
168
+ ]);
169
+ if (loadTimer !== undefined) {
170
+ clearTimeout(loadTimer);
171
+ loadTimer = undefined;
172
+ }
173
+ if (typeof loaded.adUnitId !== 'string' || loaded.adUnitId.length === 0) {
174
+ return { status: 'failed', rewardGranted: false };
175
+ }
176
+ timer = setTimeout(() => finish('timeout'), showTimeoutMs);
177
+ // Always observe rejection, even if a Dismissed event wins the race.
178
+ const showResult = sdk.showRewardVideoAd({ adId: loaded.adUnitId });
179
+ const onReward = (reward) => {
180
+ if (isRewardItem(reward)) {
181
+ rewardEarned = true;
182
+ }
183
+ };
184
+ const onShowError = () => finish('failed');
185
+ void showResult.then(onReward, onShowError);
186
+ const outcome = await terminal;
187
+ if (outcome === 'timeout') {
188
+ // Native presentation state is unknown: do not allow another show.
189
+ uncertainSdk.add(sdk);
190
+ }
191
+ let status = 'failed';
192
+ if (rewardEarned) {
193
+ status = 'completed';
194
+ }
195
+ else if (outcome === 'dismissed') {
196
+ // Mediation may deliver reward after dismissal. Only signed SSV can
197
+ // later distinguish a true skip from an earned reward.
198
+ status = 'pending';
199
+ }
200
+ else if (outcome === 'timeout') {
201
+ status = 'pending';
202
+ }
203
+ return {
204
+ status,
205
+ rewardGranted: false,
206
+ ...(status === 'completed' || status === 'pending' ? { evidence: {
207
+ schema: admobClientRewardEvidenceSchema,
208
+ payload: { adUnitId: adId },
209
+ } } : {}),
210
+ };
211
+ }
212
+ catch (error) {
213
+ const uncertain = loadTimedOut
214
+ || (nativePreflightStarted && error instanceof PreflightTimeoutError);
215
+ if (uncertain) {
216
+ uncertainSdk.add(sdk);
217
+ }
218
+ return { status: uncertain ? 'pending' : 'failed', rewardGranted: false };
219
+ }
220
+ finally {
221
+ if (loadTimer !== undefined) {
222
+ clearTimeout(loadTimer);
223
+ }
224
+ if (timer !== undefined) {
225
+ clearTimeout(timer);
226
+ }
227
+ try {
228
+ const removals = handles.map((handle) => {
229
+ const removal = Promise.resolve().then(() => handle.remove());
230
+ return withTimeout(removal, listenerCleanupTimeoutMs);
231
+ });
232
+ const settled = await Promise.allSettled(removals);
233
+ if (settled.some((result) => result.status === 'rejected')) {
234
+ uncertainSdk.add(sdk);
235
+ }
236
+ }
237
+ finally {
238
+ activeSdk.delete(sdk);
239
+ }
240
+ }
241
+ };
242
+ return {
243
+ id: 'admob-rewarded',
244
+ features: ['rewardedAds'],
245
+ methods: ['ads.preload', 'ads.showRewarded'],
246
+ async getAvailability() {
247
+ if (uncertainSdk.has(sdk) || !canRequestAds) {
248
+ return { rewardedAds: 'action-required' };
249
+ }
250
+ return { rewardedAds: activeSdk.has(sdk) ? 'temporarily-unavailable' : 'available' };
251
+ },
252
+ requestConsent,
253
+ async showPrivacyOptions() {
254
+ if (!privacyOptionsRequired) {
255
+ return;
256
+ }
257
+ canRequestAds = false;
258
+ await initialize();
259
+ try {
260
+ await sdk.showPrivacyOptionsForm();
261
+ }
262
+ finally {
263
+ await requestConsent();
264
+ }
265
+ },
266
+ bridge: {
267
+ async request(request) {
268
+ if (request.method === 'ads.preload') {
269
+ const payload = request.payload;
270
+ if (!isRecord(payload)
271
+ || (payload.format !== undefined && payload.format !== 'rewarded')
272
+ || typeof payload.placementId !== 'string'
273
+ || !adUnits.has(payload.placementId)) {
274
+ return failure(request, 'ADMOB_PLACEMENT_INVALID');
275
+ }
276
+ // Preloading an unbound ad would omit the operation's SSV key.
277
+ return response(request, undefined);
278
+ }
279
+ if (request.method !== 'ads.showRewarded' || !isRecord(request.payload)
280
+ || typeof request.payload.placementId !== 'string'
281
+ || typeof request.payload.idempotencyKey !== 'string') {
282
+ return failure(request, 'ADMOB_REQUEST_INVALID');
283
+ }
284
+ return response(request, await showRewarded(request.payload.placementId, request.payload.idempotencyKey));
285
+ },
286
+ },
287
+ };
288
+ }
@@ -0,0 +1,23 @@
1
+ import { type AppPlugin } from '@capacitor/app';
2
+ import type { LifecycleAdapter } from '@mpgd/platform';
3
+ export type CapacitorIncomingUrlKind = 'game' | 'oauth';
4
+ export interface CapacitorVisibilitySource {
5
+ readonly hidden: boolean;
6
+ addEventListener(type: 'visibilitychange', listener: () => void): void;
7
+ removeEventListener(type: 'visibilitychange', listener: () => void): void;
8
+ }
9
+ export type CapacitorAppEventsApi = Pick<AppPlugin, 'addListener' | 'getState' | 'getLaunchUrl' | 'exitApp'>;
10
+ export interface CreateCapacitorAppEventsInput {
11
+ readonly target: 'android' | 'ios';
12
+ readonly app?: CapacitorAppEventsApi;
13
+ readonly visibility?: CapacitorVisibilitySource | null;
14
+ /** Return null for URLs that this game does not own. Never default OAuth to game. */
15
+ readonly classifyIncomingUrl?: (url: string) => CapacitorIncomingUrlKind | null;
16
+ readonly historyBack?: () => void;
17
+ readonly onError?: (error: unknown) => void;
18
+ }
19
+ /**
20
+ * Owns only handles it registers. Disposing during an awaited addListener
21
+ * removes the eventual handle without touching another gateway's listeners.
22
+ */
23
+ export declare function createCapacitorAppEvents(input: CreateCapacitorAppEventsInput): LifecycleAdapter;