@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 +318 -0
- package/dist/admob.d.ts +26 -0
- package/dist/admob.js +288 -0
- package/dist/app-events.d.ts +23 -0
- package/dist/app-events.js +332 -0
- package/dist/app-store-server.d.ts +12 -0
- package/dist/app-store-server.js +119 -0
- package/dist/index.d.ts +14 -5
- package/dist/index.js +162 -13
- package/dist/native-http.d.ts +50 -0
- package/dist/native-http.js +259 -0
- package/dist/providers.d.ts +25 -0
- package/dist/providers.js +312 -0
- package/dist/viewport.d.ts +25 -0
- package/dist/viewport.js +221 -0
- package/package.json +29 -5
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.
|
package/dist/admob.d.ts
ADDED
|
@@ -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;
|