@chipmobilesdk/rn-ads 0.2.1 → 0.3.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/CHANGELOG.md +46 -0
- package/README.md +771 -671
- package/package.json +49 -49
- package/skills/sdk-rn-ads/SKILL.md +54 -5
- package/src/config/types.ts +22 -0
- package/src/config/validate.ts +211 -188
- package/src/core/adsInstance.ts +1670 -1554
- package/src/core/requestThrottle.ts +176 -0
- package/src/diagnostics/events.ts +13 -0
- package/src/index.ts +10 -0
- package/src/react/AdsBanner.tsx +337 -292
package/src/core/adsInstance.ts
CHANGED
|
@@ -1,1554 +1,1670 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The ads instance — the public contract's implementation.
|
|
3
|
-
*
|
|
4
|
-
* Every guarantee the specification treats as a rule rather than a convention
|
|
5
|
-
* converges here, and none of it needs a device to prove:
|
|
6
|
-
*
|
|
7
|
-
* FR-022 one native initialization, however many callers race
|
|
8
|
-
* FR-017 no request before the privacy gate permits one
|
|
9
|
-
* FR-011 no production ad unit outside production
|
|
10
|
-
* FR-029 one in-flight provider request per placement
|
|
11
|
-
* FR-030 exactly one full-screen ad, never a queue
|
|
12
|
-
* FR-031 a consumed ad is never reused
|
|
13
|
-
* FR-035 after dispose, a late callback updates nothing and emits nothing
|
|
14
|
-
*
|
|
15
|
-
* Nothing in this file imports the provider. It talks to the engine port, which
|
|
16
|
-
* the caller injects — the AdMob adapter in production, the fake in tests.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import {
|
|
20
|
-
isFullScreenFormat,
|
|
21
|
-
type AdFormat,
|
|
22
|
-
type AdsConfig,
|
|
23
|
-
type ConsentSnapshot,
|
|
24
|
-
type Enablement,
|
|
25
|
-
type FullScreenFormat,
|
|
26
|
-
type InitStatus,
|
|
27
|
-
type PlacementDefinition,
|
|
28
|
-
type PlacementState,
|
|
29
|
-
type PrivacyBlockReason,
|
|
30
|
-
type PrivacyOptions,
|
|
31
|
-
type UnavailableReason,
|
|
32
|
-
type Unsubscribe,
|
|
33
|
-
} from '../config/types';
|
|
34
|
-
import { validateConfig, type ResolvedConfig } from '../config/validate';
|
|
35
|
-
import { AdsError, normalizeProviderError } from '../diagnostics/errors';
|
|
36
|
-
import {
|
|
37
|
-
createEventEmitter,
|
|
38
|
-
type AdEvent,
|
|
39
|
-
type AdEventListener,
|
|
40
|
-
type ImpressionSource,
|
|
41
|
-
} from '../diagnostics/events';
|
|
42
|
-
import {
|
|
43
|
-
import
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
46
|
-
import {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
import {
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
56
|
-
import {
|
|
57
|
-
import {
|
|
58
|
-
import {
|
|
59
|
-
import {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
| { readonly status: '
|
|
70
|
-
| { readonly status: '
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
| { readonly status: '
|
|
74
|
-
| { readonly status: '
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
| { readonly status: '
|
|
79
|
-
| { readonly status: '
|
|
80
|
-
| { readonly status: '
|
|
81
|
-
| { readonly status: '
|
|
82
|
-
| { readonly status: '
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
| { readonly status: '
|
|
87
|
-
| { readonly status: '
|
|
88
|
-
| { readonly status: '
|
|
89
|
-
| { readonly status: '
|
|
90
|
-
| { readonly status: '
|
|
91
|
-
| { readonly status: '
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
| { readonly status: '
|
|
111
|
-
| { readonly status: '
|
|
112
|
-
| { readonly status: '
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
*
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
for (const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
function
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
return result;
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
if (
|
|
949
|
-
return { status: '
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
if (
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
return { status: '
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
return
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
//
|
|
1217
|
-
//
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
if (runtime.snapshot.state === '
|
|
1225
|
-
return { status: '
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
const
|
|
1251
|
-
runtime.
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
return '
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
(
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The ads instance — the public contract's implementation.
|
|
3
|
+
*
|
|
4
|
+
* Every guarantee the specification treats as a rule rather than a convention
|
|
5
|
+
* converges here, and none of it needs a device to prove:
|
|
6
|
+
*
|
|
7
|
+
* FR-022 one native initialization, however many callers race
|
|
8
|
+
* FR-017 no request before the privacy gate permits one
|
|
9
|
+
* FR-011 no production ad unit outside production
|
|
10
|
+
* FR-029 one in-flight provider request per placement
|
|
11
|
+
* FR-030 exactly one full-screen ad, never a queue
|
|
12
|
+
* FR-031 a consumed ad is never reused
|
|
13
|
+
* FR-035 after dispose, a late callback updates nothing and emits nothing
|
|
14
|
+
*
|
|
15
|
+
* Nothing in this file imports the provider. It talks to the engine port, which
|
|
16
|
+
* the caller injects — the AdMob adapter in production, the fake in tests.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
isFullScreenFormat,
|
|
21
|
+
type AdFormat,
|
|
22
|
+
type AdsConfig,
|
|
23
|
+
type ConsentSnapshot,
|
|
24
|
+
type Enablement,
|
|
25
|
+
type FullScreenFormat,
|
|
26
|
+
type InitStatus,
|
|
27
|
+
type PlacementDefinition,
|
|
28
|
+
type PlacementState,
|
|
29
|
+
type PrivacyBlockReason,
|
|
30
|
+
type PrivacyOptions,
|
|
31
|
+
type UnavailableReason,
|
|
32
|
+
type Unsubscribe,
|
|
33
|
+
} from '../config/types';
|
|
34
|
+
import { validateConfig, type ResolvedConfig } from '../config/validate';
|
|
35
|
+
import { AdsError, normalizeProviderError } from '../diagnostics/errors';
|
|
36
|
+
import {
|
|
37
|
+
createEventEmitter,
|
|
38
|
+
type AdEvent,
|
|
39
|
+
type AdEventListener,
|
|
40
|
+
type ImpressionSource,
|
|
41
|
+
} from '../diagnostics/events';
|
|
42
|
+
import { createRequestThrottle } from './requestThrottle';
|
|
43
|
+
import { createCorrelationIssuer } from '../diagnostics/correlation';
|
|
44
|
+
import type { AdsEngine, EngineAdEvent, EngineAdHandle } from '../engine/port';
|
|
45
|
+
import { createFullScreenLock } from './fullScreenLock';
|
|
46
|
+
import { canRequest, canStart, initialInit, initReducer, type InitSnapshot } from './initMachine';
|
|
47
|
+
import {
|
|
48
|
+
canLoad,
|
|
49
|
+
initialPlacement,
|
|
50
|
+
placementReducer,
|
|
51
|
+
type PlacementSnapshot,
|
|
52
|
+
} from './placementMachine';
|
|
53
|
+
import { evaluateGate, requiresReinitialization, toEnginePrivacy } from './privacyGate';
|
|
54
|
+
import { evaluateGates, type ConsentGateInput, type GateDecision } from './gates';
|
|
55
|
+
import { createRewardedSession, type RewardedSession } from './rewardedSession';
|
|
56
|
+
import { createTimerSet, isStale, type Scheduler } from './timers';
|
|
57
|
+
import { canResume, permitsRequests, type EnablementResult } from './enablement';
|
|
58
|
+
import { canRetry, delayForAttempt } from './retryPolicy';
|
|
59
|
+
import { createAdOriginatedTracker } from './adOriginatedReturn';
|
|
60
|
+
import {
|
|
61
|
+
buildConsentOptions,
|
|
62
|
+
INITIAL_CONSENT,
|
|
63
|
+
needsForm,
|
|
64
|
+
runConsentStep,
|
|
65
|
+
toGateInput,
|
|
66
|
+
} from './consentFlow';
|
|
67
|
+
|
|
68
|
+
export type InitResult =
|
|
69
|
+
| { readonly status: 'ready' }
|
|
70
|
+
| { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
|
|
71
|
+
| { readonly status: 'disabled' }
|
|
72
|
+
/** The app has not decided whether ads are on. Nothing was requested (FR-115). */
|
|
73
|
+
| { readonly status: 'undecided' }
|
|
74
|
+
| { readonly status: 'unavailable'; readonly reason: UnavailableReason }
|
|
75
|
+
| { readonly status: 'failed'; readonly error: AdsError };
|
|
76
|
+
|
|
77
|
+
export type PrivacyUpdateResult =
|
|
78
|
+
| { readonly status: 'applied' }
|
|
79
|
+
| { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
|
|
80
|
+
| { readonly status: 'requires_reinitialization' }
|
|
81
|
+
| { readonly status: 'disabled' }
|
|
82
|
+
| { readonly status: 'undecided' }
|
|
83
|
+
| { readonly status: 'unavailable'; readonly reason: UnavailableReason };
|
|
84
|
+
|
|
85
|
+
export type LoadResult =
|
|
86
|
+
| { readonly status: 'loaded'; readonly correlationId: string }
|
|
87
|
+
| { readonly status: 'disabled' }
|
|
88
|
+
| { readonly status: 'undecided' }
|
|
89
|
+
| { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
|
|
90
|
+
| { readonly status: 'unavailable'; readonly reason: UnavailableReason }
|
|
91
|
+
| { readonly status: 'no_fill' }
|
|
92
|
+
/**
|
|
93
|
+
* Refused by the per-placement request throttle (FR-140), added in 0.3.0.
|
|
94
|
+
*
|
|
95
|
+
* Nothing was requested and nothing is broken — this placement asked too
|
|
96
|
+
* recently. Distinct from `no_fill` on purpose: no-fill means the network had
|
|
97
|
+
* nothing to give, this means we did not ask. An app that treats them the same
|
|
98
|
+
* will misdiagnose the first time a banner goes quiet.
|
|
99
|
+
*/
|
|
100
|
+
| ThrottledResult
|
|
101
|
+
| { readonly status: 'timeout' }
|
|
102
|
+
| { readonly status: 'failed'; readonly error: AdsError };
|
|
103
|
+
|
|
104
|
+
export interface ThrottledResult {
|
|
105
|
+
readonly status: 'throttled';
|
|
106
|
+
readonly retryAfterMs: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type ShowResult =
|
|
110
|
+
| { readonly status: 'shown'; readonly correlationId: string }
|
|
111
|
+
| { readonly status: 'not_ready' }
|
|
112
|
+
| { readonly status: 'busy' }
|
|
113
|
+
| { readonly status: 'consumed' }
|
|
114
|
+
| { readonly status: 'app_inactive' }
|
|
115
|
+
/**
|
|
116
|
+
* The user came back to the app from an ad, or from a browser an ad opened,
|
|
117
|
+
* so an app-open ad would be an ad immediately after an ad. Distinct from
|
|
118
|
+
* `busy` and `not_ready` because the app cannot detect this itself and
|
|
119
|
+
* therefore cannot infer it from either of those (FR-128).
|
|
120
|
+
*/
|
|
121
|
+
| { readonly status: 'ad_originated_return' }
|
|
122
|
+
| { readonly status: 'disabled' }
|
|
123
|
+
| { readonly status: 'undecided' }
|
|
124
|
+
| { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
|
|
125
|
+
| { readonly status: 'unavailable'; readonly reason: UnavailableReason }
|
|
126
|
+
| { readonly status: 'timeout' }
|
|
127
|
+
| { readonly status: 'failed'; readonly error: AdsError };
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The optional consent capability (FR-101).
|
|
131
|
+
*
|
|
132
|
+
* Grouped under its own namespace rather than flattened onto the instance so
|
|
133
|
+
* that FR-102's optionality stays legible: an app that never touches
|
|
134
|
+
* `ads.consent` never starts a flow. Five loose methods on the instance would
|
|
135
|
+
* invite the assumption that they are part of the normal path.
|
|
136
|
+
*/
|
|
137
|
+
export interface ConsentApi {
|
|
138
|
+
/** Refreshes consent information from the provider. */
|
|
139
|
+
requestInfoUpdate(): Promise<ConsentSnapshot>;
|
|
140
|
+
/** Presents the provider's form if one is required. Renders nothing itself. */
|
|
141
|
+
showFormIfRequired(): Promise<ConsentSnapshot>;
|
|
142
|
+
getState(): ConsentSnapshot;
|
|
143
|
+
subscribe(listener: (snapshot: ConsentSnapshot) => void): Unsubscribe;
|
|
144
|
+
/** Clears the stored decision. Inert in production (FR-113). */
|
|
145
|
+
reset(): Promise<void>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface AdsInstance {
|
|
149
|
+
initialize(privacy: PrivacyOptions): Promise<InitResult>;
|
|
150
|
+
updatePrivacy(privacy: PrivacyOptions): Promise<PrivacyUpdateResult>;
|
|
151
|
+
/**
|
|
152
|
+
* Turns ads on or off. Returns a result rather than `void` (FR-122).
|
|
153
|
+
*
|
|
154
|
+
* 0.1.0 returned nothing, which meant an app re-enabling ads after a lapsed
|
|
155
|
+
* subscription had no way to learn that it also had to re-initialize — and
|
|
156
|
+
* every load and show after that was refused in silence.
|
|
157
|
+
*/
|
|
158
|
+
setEnabled(enabled: boolean): EnablementResult;
|
|
159
|
+
/** The same, including the third state the app has not resolved yet. */
|
|
160
|
+
setEnablement(next: Enablement): EnablementResult;
|
|
161
|
+
setAppActive(active: boolean): void;
|
|
162
|
+
readonly consent: ConsentApi;
|
|
163
|
+
getStatus(): InitStatus;
|
|
164
|
+
subscribeStatus(listener: (status: InitStatus) => void): Unsubscribe;
|
|
165
|
+
|
|
166
|
+
load(placementKey: string): Promise<LoadResult>;
|
|
167
|
+
show(placementKey: string): Promise<ShowResult>;
|
|
168
|
+
getPlacementState(placementKey: string): PlacementState;
|
|
169
|
+
subscribePlacement(key: string, listener: (state: PlacementState) => void): Unsubscribe;
|
|
170
|
+
|
|
171
|
+
subscribeEvents(listener: AdEventListener): Unsubscribe;
|
|
172
|
+
|
|
173
|
+
/** FR-011a — substitution is observable state, not only a log line. */
|
|
174
|
+
getSubstitutedPlacements(): readonly string[];
|
|
175
|
+
/** FR-113 — the same treatment for consent tools dropped in production. */
|
|
176
|
+
getIgnoredConsentTools(): readonly string[];
|
|
177
|
+
/** Internal wiring for the banner host; not part of the documented surface. */
|
|
178
|
+
readonly internals: InstanceInternals;
|
|
179
|
+
|
|
180
|
+
dispose(): void;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** What `AdsBanner` needs. Deliberately not documented as consumer API. */
|
|
184
|
+
export interface InstanceInternals {
|
|
185
|
+
resolveBanner(placementKey: string):
|
|
186
|
+
| { readonly ok: true; readonly adUnitId: string; readonly correlationId: string }
|
|
187
|
+
| { readonly ok: false; readonly result: LoadResult };
|
|
188
|
+
/**
|
|
189
|
+
* Takes the throttle slot for a banner cycle (FR-140).
|
|
190
|
+
*
|
|
191
|
+
* Called from the banner host's effect, after `resolveBanner` allowed it and
|
|
192
|
+
* immediately before the provider component mounts. Idempotent for one
|
|
193
|
+
* `correlationId`, so React running the effect twice charges one slot.
|
|
194
|
+
*
|
|
195
|
+
* Returns the refusal if the window closed between resolve and commit — rare,
|
|
196
|
+
* but a second banner for the same placement mounting in the same tick would
|
|
197
|
+
* do it.
|
|
198
|
+
*
|
|
199
|
+
* Emits nothing and sets no state: it is called during render, so the host
|
|
200
|
+
* reports the refusal from its effect instead.
|
|
201
|
+
*/
|
|
202
|
+
commitBannerRequest(
|
|
203
|
+
placementKey: string,
|
|
204
|
+
correlationId: string,
|
|
205
|
+
): ThrottledResult | null;
|
|
206
|
+
/** The configured floor for a placement, so the host can report the cause. */
|
|
207
|
+
throttleFloorFor(placementKey: string): number;
|
|
208
|
+
emit(event: AdEvent): void;
|
|
209
|
+
setPlacementState(key: string, state: PlacementState): void;
|
|
210
|
+
isReady(): boolean;
|
|
211
|
+
nextCorrelationId(): string;
|
|
212
|
+
timerCount(): number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface CreateAdsOptions {
|
|
216
|
+
/** Injected in tests; the AdMob adapter is the default in production. */
|
|
217
|
+
readonly engine?: AdsEngine;
|
|
218
|
+
/** Injected so the FR-012 rule is testable without touching a global. */
|
|
219
|
+
readonly isDevBuild?: boolean;
|
|
220
|
+
readonly now?: () => number;
|
|
221
|
+
/**
|
|
222
|
+
* Injected so retry backoff, app-open expiry, and the undecided warning can
|
|
223
|
+
* be driven by a test clock instead of by waiting. Production uses real
|
|
224
|
+
* timers.
|
|
225
|
+
*/
|
|
226
|
+
readonly scheduler?: Scheduler;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
interface PlacementRuntime {
|
|
230
|
+
snapshot: PlacementSnapshot;
|
|
231
|
+
/**
|
|
232
|
+
* The cycle's correlation id, held here rather than read from the snapshot:
|
|
233
|
+
* the reducer resets the snapshot on dismissal, and events emitted around
|
|
234
|
+
* that transition must still carry the id of the cycle they belong to.
|
|
235
|
+
*/
|
|
236
|
+
correlationId: string | null;
|
|
237
|
+
/**
|
|
238
|
+
* Whether this cycle already reported a dismissal. Format-independent — the
|
|
239
|
+
* rewarded session guards its own, but interstitials need the same guard and
|
|
240
|
+
* would otherwise report a repeated provider callback twice.
|
|
241
|
+
*/
|
|
242
|
+
dismissed: boolean;
|
|
243
|
+
handle: EngineAdHandle | null;
|
|
244
|
+
unsubscribeEngine: Unsubscribe | null;
|
|
245
|
+
inFlightLoad: Promise<LoadResult> | null;
|
|
246
|
+
cancelLoadTimer: Unsubscribe | null;
|
|
247
|
+
cancelShowTimer: Unsubscribe | null;
|
|
248
|
+
rewarded: RewardedSession | null;
|
|
249
|
+
listeners: Set<(state: PlacementState) => void>;
|
|
250
|
+
/** Retries already spent in this load cycle. Reset when a new cycle starts. */
|
|
251
|
+
retryAttempts: number;
|
|
252
|
+
/** Cancels a scheduled retry. Called by disable, undecided, and dispose. */
|
|
253
|
+
cancelRetry: Unsubscribe | null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function detectDevBuild(): boolean {
|
|
257
|
+
return typeof __DEV__ !== 'undefined' ? Boolean(__DEV__) : false;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function createAds(config: AdsConfig, options: CreateAdsOptions = {}): AdsInstance {
|
|
261
|
+
const isDevBuild = options.isDevBuild ?? detectDevBuild();
|
|
262
|
+
const now = options.now ?? (() => Date.now());
|
|
263
|
+
|
|
264
|
+
// Throws on a structural fault or the FR-012 fail-closed case. Deliberately
|
|
265
|
+
// before anything else: a misconfigured instance should not exist at all.
|
|
266
|
+
const resolved: ResolvedConfig = validateConfig(config, { isDevBuild });
|
|
267
|
+
|
|
268
|
+
const engine: AdsEngine = options.engine ?? createDefaultEngine();
|
|
269
|
+
const events = createEventEmitter();
|
|
270
|
+
const correlation = createCorrelationIssuer();
|
|
271
|
+
// FR-140. Account safety, not product policy — see core/requestThrottle.ts.
|
|
272
|
+
const requestThrottle = createRequestThrottle(now);
|
|
273
|
+
const timers = createTimerSet(options.scheduler);
|
|
274
|
+
const lock = createFullScreenLock();
|
|
275
|
+
const adReturn = createAdOriginatedTracker();
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* An instance told it is `undecided` says so from the moment it exists.
|
|
279
|
+
*
|
|
280
|
+
* Without this it reports `not_started` until `initialize()` runs, which is
|
|
281
|
+
* indistinguishable from a disabled instance at exactly the point an app is
|
|
282
|
+
* deciding whether to reserve the banner's space — so FR-116 would hold only
|
|
283
|
+
* after a call the app has no reason to make yet. Found on device, not in a
|
|
284
|
+
* unit test, because the demo screen defers initialization the way a real
|
|
285
|
+
* entitlement-gated app does.
|
|
286
|
+
*
|
|
287
|
+
* `disabled` deliberately keeps 0.1.0's `not_started` here: that behavior is
|
|
288
|
+
* observable today and BC-004 protects it. The asymmetry is the cost of not
|
|
289
|
+
* changing something that already shipped.
|
|
290
|
+
*/
|
|
291
|
+
let init: InitSnapshot =
|
|
292
|
+
resolved.enablement === 'undecided'
|
|
293
|
+
? initReducer(initialInit, { type: 'UNDECIDE' })
|
|
294
|
+
: initialInit;
|
|
295
|
+
let enablement: Enablement = resolved.enablement;
|
|
296
|
+
let appActive = true;
|
|
297
|
+
let consent: ConsentSnapshot = INITIAL_CONSENT;
|
|
298
|
+
let consentStarted = false;
|
|
299
|
+
let undecidedWarned = false;
|
|
300
|
+
let cancelUndecidedTimer: Unsubscribe | null = null;
|
|
301
|
+
let appliedPrivacy: PrivacyOptions | null = null;
|
|
302
|
+
let currentPrivacy: PrivacyOptions | null = null;
|
|
303
|
+
let inFlightInit: Promise<InitResult> | null = null;
|
|
304
|
+
let disposed = false;
|
|
305
|
+
let warnedSubstitution = false;
|
|
306
|
+
|
|
307
|
+
const statusListeners = new Set<(status: InitStatus) => void>();
|
|
308
|
+
const placements = new Map<string, PlacementRuntime>();
|
|
309
|
+
|
|
310
|
+
for (const key of resolved.placements.keys()) {
|
|
311
|
+
placements.set(key, {
|
|
312
|
+
snapshot: initialPlacement,
|
|
313
|
+
correlationId: null,
|
|
314
|
+
dismissed: false,
|
|
315
|
+
handle: null,
|
|
316
|
+
unsubscribeEngine: null,
|
|
317
|
+
inFlightLoad: null,
|
|
318
|
+
cancelLoadTimer: null,
|
|
319
|
+
cancelShowTimer: null,
|
|
320
|
+
rewarded: null,
|
|
321
|
+
listeners: new Set(),
|
|
322
|
+
retryAttempts: 0,
|
|
323
|
+
cancelRetry: null,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const consentListeners = new Set<(snapshot: ConsentSnapshot) => void>();
|
|
328
|
+
|
|
329
|
+
function setConsent(next: ConsentSnapshot): void {
|
|
330
|
+
consent = next;
|
|
331
|
+
for (const listener of Array.from(consentListeners)) {
|
|
332
|
+
try {
|
|
333
|
+
listener(next);
|
|
334
|
+
} catch {
|
|
335
|
+
// Consumer code; isolated exactly as event listeners are (FR-056).
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The consent flow's contribution to the gate, or `null` when the app never
|
|
342
|
+
* opted in.
|
|
343
|
+
*
|
|
344
|
+
* `null` is what makes FR-102 structural rather than conditional: with no
|
|
345
|
+
* flow started, the gate sees only the app's own input and behaves exactly as
|
|
346
|
+
* 0.1.0 did. There is no code path to skip and therefore none to forget.
|
|
347
|
+
*/
|
|
348
|
+
function consentGateInput(): ConsentGateInput {
|
|
349
|
+
return consentStarted ? toGateInput(consent) : null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Warns once if the app leaves `undecided` unresolved (FR-119).
|
|
354
|
+
*
|
|
355
|
+
* Once, not repeatedly: a warning that fires every ten seconds becomes noise
|
|
356
|
+
* that gets filtered, which defeats the diagnostic. And it only warns — the
|
|
357
|
+
* package never resolves the state itself, because doing so would mean
|
|
358
|
+
* guessing at exactly the business decision FR-120 forbids it from knowing.
|
|
359
|
+
*/
|
|
360
|
+
function armUndecidedWarning(): void {
|
|
361
|
+
if (enablement !== 'undecided' || undecidedWarned || cancelUndecidedTimer) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const armedAt = now();
|
|
365
|
+
cancelUndecidedTimer = timers.after(resolved.timeouts.undecidedWarning, () => {
|
|
366
|
+
cancelUndecidedTimer = null;
|
|
367
|
+
if (disposed || enablement !== 'undecided' || undecidedWarned) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
undecidedWarned = true;
|
|
371
|
+
events.emit({
|
|
372
|
+
type: 'config_warning',
|
|
373
|
+
timestamp: now(),
|
|
374
|
+
warning: 'enablement_undecided_timeout',
|
|
375
|
+
placementKeys: [],
|
|
376
|
+
elapsedMs: now() - armedAt,
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function disarmUndecidedWarning(): void {
|
|
382
|
+
cancelUndecidedTimer?.();
|
|
383
|
+
cancelUndecidedTimer = null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Cancels every pending retry, everywhere (FR-137). */
|
|
387
|
+
function cancelAllRetries(): void {
|
|
388
|
+
for (const runtime of placements.values()) {
|
|
389
|
+
runtime.cancelRetry?.();
|
|
390
|
+
runtime.cancelRetry = null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function setInit(next: InitSnapshot): void {
|
|
395
|
+
if (next === init || next.status === init.status) {
|
|
396
|
+
init = next;
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
init = next;
|
|
400
|
+
events.emit({
|
|
401
|
+
type: 'initialization',
|
|
402
|
+
timestamp: now(),
|
|
403
|
+
status: next.status,
|
|
404
|
+
retryable: next.error?.retryable,
|
|
405
|
+
});
|
|
406
|
+
for (const listener of Array.from(statusListeners)) {
|
|
407
|
+
try {
|
|
408
|
+
listener(next.status);
|
|
409
|
+
} catch {
|
|
410
|
+
// A status listener is consumer code; its failure is isolated the
|
|
411
|
+
// same way an event listener's is (FR-056).
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function setPlacement(key: string, next: PlacementSnapshot): void {
|
|
417
|
+
const runtime = placements.get(key);
|
|
418
|
+
if (!runtime || runtime.snapshot.state === next.state) {
|
|
419
|
+
if (runtime) {
|
|
420
|
+
runtime.snapshot = next;
|
|
421
|
+
}
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
runtime.snapshot = next;
|
|
425
|
+
for (const listener of Array.from(runtime.listeners)) {
|
|
426
|
+
try {
|
|
427
|
+
listener(next.state);
|
|
428
|
+
} catch {
|
|
429
|
+
// Isolated, as above.
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function dispatchPlacement(key: string, event: Parameters<typeof placementReducer>[1]): void {
|
|
435
|
+
const runtime = placements.get(key);
|
|
436
|
+
const definition = resolved.placements.get(key);
|
|
437
|
+
if (!runtime || !definition) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
setPlacement(key, placementReducer(runtime.snapshot, event, definition.format));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The single gate evaluation (FR-140).
|
|
445
|
+
*
|
|
446
|
+
* Assembles the three inputs and defers the precedence entirely to
|
|
447
|
+
* `evaluateGates`. Nothing in this file compares gate state itself — the
|
|
448
|
+
* whole point of FR-140 is that the order is a decision made once, not four
|
|
449
|
+
* `if` chains that happen to agree today.
|
|
450
|
+
*
|
|
451
|
+
* Note the order differs from 0.1.0's, which checked `enabled` before
|
|
452
|
+
* availability. That was never observable in isolation, only when two gates
|
|
453
|
+
* were closed at once, and the case it changes — disabled on an unsupported
|
|
454
|
+
* platform, previously decided by whichever transition arrived last — is the
|
|
455
|
+
* incidental ordering FR-140a exists to fix.
|
|
456
|
+
*/
|
|
457
|
+
function currentGate(): GateDecision {
|
|
458
|
+
return evaluateGates({
|
|
459
|
+
available: engine.isAvailable()
|
|
460
|
+
? { ok: true }
|
|
461
|
+
: { ok: false, reason: 'unsupported_platform' },
|
|
462
|
+
enablement,
|
|
463
|
+
appGate: (() => {
|
|
464
|
+
const gate = evaluateGate(currentPrivacy);
|
|
465
|
+
return gate.allowed
|
|
466
|
+
? ({ permitted: true } as const)
|
|
467
|
+
: ({ permitted: false, reason: gate.reason } as const);
|
|
468
|
+
})(),
|
|
469
|
+
consentGate: consentGateInput(),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Maps a closed gate onto the refusal shape load, show, and the banner share. */
|
|
474
|
+
function refusalFor(
|
|
475
|
+
decision: Extract<GateDecision, { allowed: false }>,
|
|
476
|
+
): Extract<LoadResult, { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }> {
|
|
477
|
+
switch (decision.kind) {
|
|
478
|
+
case 'unavailable':
|
|
479
|
+
return { status: 'unavailable', reason: decision.reason };
|
|
480
|
+
case 'disabled':
|
|
481
|
+
return { status: 'disabled' };
|
|
482
|
+
case 'undecided':
|
|
483
|
+
return { status: 'undecided' };
|
|
484
|
+
default:
|
|
485
|
+
return { status: 'blocked', reason: decision.reason };
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Every refusal that applies before a placement is even looked up. */
|
|
490
|
+
function gateCheck():
|
|
491
|
+
| { readonly ok: true }
|
|
492
|
+
| {
|
|
493
|
+
readonly ok: false;
|
|
494
|
+
readonly result: Extract<
|
|
495
|
+
LoadResult,
|
|
496
|
+
{ status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }
|
|
497
|
+
>;
|
|
498
|
+
} {
|
|
499
|
+
const decision = currentGate();
|
|
500
|
+
if (decision.allowed) {
|
|
501
|
+
return { ok: true };
|
|
502
|
+
}
|
|
503
|
+
return { ok: false, result: refusalFor(decision) };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function emitRefusal(
|
|
507
|
+
operation: 'initialize' | 'load' | 'show',
|
|
508
|
+
result: { readonly status: string; readonly reason?: string },
|
|
509
|
+
placementKey?: string,
|
|
510
|
+
format?: AdFormat,
|
|
511
|
+
): void {
|
|
512
|
+
if (result.status === 'disabled' || result.status === 'undecided') {
|
|
513
|
+
// `undecided` reuses the `disabled` event rather than minting a new
|
|
514
|
+
// one: for an observer of the event stream the fact is the same — no
|
|
515
|
+
// request was made — and the distinction that matters is carried by
|
|
516
|
+
// the typed result and the init status, where the app reads it.
|
|
517
|
+
events.emit({ type: 'disabled', timestamp: now(), operation, placementKey, format });
|
|
518
|
+
} else if (result.status === 'unavailable' || result.status === 'blocked') {
|
|
519
|
+
events.emit({
|
|
520
|
+
type: 'unavailable',
|
|
521
|
+
timestamp: now(),
|
|
522
|
+
operation,
|
|
523
|
+
placementKey,
|
|
524
|
+
format,
|
|
525
|
+
reason: (result.reason ?? 'unsupported_platform') as UnavailableReason | PrivacyBlockReason,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Consumes a throttle slot for a placement, or reports the refusal (FR-140).
|
|
532
|
+
*
|
|
533
|
+
* Returns `null` when the request may proceed, so the two call sites read as a
|
|
534
|
+
* guard clause. On refusal it moves the placement to `throttled` and emits,
|
|
535
|
+
* which is what makes the refusal observable rather than a silent absence of
|
|
536
|
+
* ads — the app would otherwise have no way to tell this from no-fill.
|
|
537
|
+
*/
|
|
538
|
+
function reportThrottled(
|
|
539
|
+
key: string,
|
|
540
|
+
format: AdFormat,
|
|
541
|
+
retryAfterMs: number,
|
|
542
|
+
minRequestIntervalMs: number,
|
|
543
|
+
): LoadResult {
|
|
544
|
+
const runtime = placements.get(key);
|
|
545
|
+
if (runtime) {
|
|
546
|
+
setPlacement(key, { ...runtime.snapshot, state: 'throttled' });
|
|
547
|
+
}
|
|
548
|
+
events.emit({
|
|
549
|
+
type: 'request_throttled',
|
|
550
|
+
timestamp: now(),
|
|
551
|
+
placementKey: key,
|
|
552
|
+
format,
|
|
553
|
+
retryAfterMs,
|
|
554
|
+
minRequestIntervalMs,
|
|
555
|
+
});
|
|
556
|
+
return { status: 'throttled', retryAfterMs };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Consumes a slot, or reports the refusal. `null` means proceed. */
|
|
560
|
+
function checkThrottle(key: string, format: AdFormat, token?: string): LoadResult | null {
|
|
561
|
+
const minRequestIntervalMs = resolved.minRequestIntervalByKey.get(key) ?? 0;
|
|
562
|
+
const decision = requestThrottle.tryAcquire(key, minRequestIntervalMs, token);
|
|
563
|
+
return decision.allowed
|
|
564
|
+
? null
|
|
565
|
+
: reportThrottled(key, format, decision.retryAfterMs, minRequestIntervalMs);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function warnSubstitutionOnce(): void {
|
|
569
|
+
if (warnedSubstitution || resolved.catalog.substitutedKeys.length === 0) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
warnedSubstitution = true;
|
|
573
|
+
events.emit({
|
|
574
|
+
type: 'config_warning',
|
|
575
|
+
timestamp: now(),
|
|
576
|
+
warning: 'test_ad_unit_substituted',
|
|
577
|
+
placementKeys: resolved.catalog.substitutedKeys,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function releasePlacementResources(key: string): void {
|
|
582
|
+
const runtime = placements.get(key);
|
|
583
|
+
if (!runtime) {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
runtime.cancelLoadTimer?.();
|
|
587
|
+
runtime.cancelLoadTimer = null;
|
|
588
|
+
runtime.cancelShowTimer?.();
|
|
589
|
+
runtime.cancelShowTimer = null;
|
|
590
|
+
runtime.unsubscribeEngine?.();
|
|
591
|
+
runtime.unsubscribeEngine = null;
|
|
592
|
+
if (runtime.handle) {
|
|
593
|
+
engine.dispose(runtime.handle);
|
|
594
|
+
runtime.handle = null;
|
|
595
|
+
}
|
|
596
|
+
runtime.inFlightLoad = null;
|
|
597
|
+
runtime.rewarded = null;
|
|
598
|
+
runtime.cancelRetry?.();
|
|
599
|
+
runtime.cancelRetry = null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** App-open expires on its own, shorter window (FR-130). */
|
|
603
|
+
function validityFor(format: AdFormat): number {
|
|
604
|
+
return format === 'app_open'
|
|
605
|
+
? resolved.timeouts.appOpenValidity
|
|
606
|
+
: resolved.timeouts.adValidity;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function doInitialize(privacy: PrivacyOptions): Promise<InitResult> {
|
|
610
|
+
const enginePrivacy = toEnginePrivacy(privacy, resolved.testDeviceIds);
|
|
611
|
+
try {
|
|
612
|
+
// The adapter applies privacy configuration before initializing the
|
|
613
|
+
// provider; that ordering is the whole reason FR-017 is enforceable.
|
|
614
|
+
await engine.initialize(enginePrivacy);
|
|
615
|
+
appliedPrivacy = privacy;
|
|
616
|
+
setInit(initReducer(init, { type: 'SUCCEED' }));
|
|
617
|
+
warnSubstitutionOnce();
|
|
618
|
+
return { status: 'ready' };
|
|
619
|
+
} catch (error) {
|
|
620
|
+
const normalized = normalizeProviderError(error, { operation: 'initialize' });
|
|
621
|
+
setInit(initReducer(init, { type: 'FAIL', error: normalized }));
|
|
622
|
+
return { status: 'failed', error: normalized };
|
|
623
|
+
} finally {
|
|
624
|
+
inFlightInit = null;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function handleEngineEvent(
|
|
629
|
+
key: string,
|
|
630
|
+
definition: PlacementDefinition,
|
|
631
|
+
event: EngineAdEvent,
|
|
632
|
+
): void {
|
|
633
|
+
// After dispose nothing is honored: a late provider callback must not
|
|
634
|
+
// update a consumer that has gone away (FR-035, SC-008).
|
|
635
|
+
if (disposed) {
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const runtime = placements.get(key);
|
|
639
|
+
if (!runtime) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const correlationId = runtime.correlationId ?? undefined;
|
|
643
|
+
const base = { timestamp: now(), placementKey: key, format: definition.format, correlationId };
|
|
644
|
+
|
|
645
|
+
switch (event.type) {
|
|
646
|
+
case 'opened': {
|
|
647
|
+
// The provider confirming an ad is on screen is what arms
|
|
648
|
+
// ad-originated return detection (FR-128). Taken from here rather
|
|
649
|
+
// than from the app's background report, which arrives too late to
|
|
650
|
+
// be trusted — see core/adOriginatedReturn.ts.
|
|
651
|
+
if (isFullScreenFormat(definition.format)) {
|
|
652
|
+
adReturn.noteFullScreenShown(now());
|
|
653
|
+
}
|
|
654
|
+
events.emit({ ...base, type: 'display_started' });
|
|
655
|
+
// R5: the provider exposes no impression callback for full-screen
|
|
656
|
+
// formats, so this one is derived from the ad opening. Labelled
|
|
657
|
+
// honestly rather than passed off as the provider's own.
|
|
658
|
+
events.emit({ ...base, type: 'impression', source: 'derived' as ImpressionSource });
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
case 'clicked':
|
|
662
|
+
// Recorded so that a background transition moments later is
|
|
663
|
+
// recognized as "the ad opened a browser" — the only signal the
|
|
664
|
+
// provider gives for that case (FR-128).
|
|
665
|
+
adReturn.noteClick(now());
|
|
666
|
+
events.emit({ ...base, type: 'click' });
|
|
667
|
+
break;
|
|
668
|
+
case 'rewarded': {
|
|
669
|
+
const first = runtime.rewarded?.confirmReward(event.reward) ?? null;
|
|
670
|
+
if (first) {
|
|
671
|
+
events.emit({ ...base, type: 'reward_earned', reward: first });
|
|
672
|
+
}
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
case 'dismissed': {
|
|
676
|
+
const rewardedFirst = runtime.rewarded ? runtime.rewarded.dismiss() : true;
|
|
677
|
+
if (runtime.dismissed || !rewardedFirst) {
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
runtime.dismissed = true;
|
|
681
|
+
lock.release(key);
|
|
682
|
+
runtime.cancelShowTimer?.();
|
|
683
|
+
runtime.cancelShowTimer = null;
|
|
684
|
+
events.emit({
|
|
685
|
+
...base,
|
|
686
|
+
type: 'dismissed',
|
|
687
|
+
rewardEarned: runtime.rewarded ? runtime.rewarded.rewardEarned : undefined,
|
|
688
|
+
});
|
|
689
|
+
dispatchPlacement(key, { type: 'DISMISSED' });
|
|
690
|
+
// Deliberately NOT released here. FR-049 requires the reward and
|
|
691
|
+
// the dismissal to be handled in either order, and a provider that
|
|
692
|
+
// confirms the reward *after* closing the ad would otherwise have
|
|
693
|
+
// its confirmation dropped by a torn-down listener — silently
|
|
694
|
+
// costing the user a reward they earned.
|
|
695
|
+
//
|
|
696
|
+
// This is a retained resource, not a leak: at most one handle and
|
|
697
|
+
// one listener per placement, released deterministically by the
|
|
698
|
+
// next load of that placement or by dispose().
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
case 'failed': {
|
|
702
|
+
const normalized = normalizeProviderError(event.cause, {
|
|
703
|
+
operation: 'show',
|
|
704
|
+
placementKey: key,
|
|
705
|
+
format: definition.format,
|
|
706
|
+
});
|
|
707
|
+
lock.release(key);
|
|
708
|
+
events.emit({ ...base, type: 'display_failed', error: normalized });
|
|
709
|
+
dispatchPlacement(key, { type: 'SHOW_FAILED', error: normalized });
|
|
710
|
+
releasePlacementResources(key);
|
|
711
|
+
break;
|
|
712
|
+
}
|
|
713
|
+
default:
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function loadFullScreen(
|
|
719
|
+
key: string,
|
|
720
|
+
definition: PlacementDefinition,
|
|
721
|
+
adUnitId: string,
|
|
722
|
+
correlationId: string,
|
|
723
|
+
): Promise<LoadResult> {
|
|
724
|
+
const runtime = placements.get(key)!;
|
|
725
|
+
const format = definition.format as FullScreenFormat;
|
|
726
|
+
const startedAt = now();
|
|
727
|
+
|
|
728
|
+
return new Promise<LoadResult>(resolve => {
|
|
729
|
+
let settled = false;
|
|
730
|
+
|
|
731
|
+
const finish = (result: LoadResult) => {
|
|
732
|
+
if (settled) {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
settled = true;
|
|
736
|
+
runtime.cancelLoadTimer?.();
|
|
737
|
+
runtime.cancelLoadTimer = null;
|
|
738
|
+
runtime.inFlightLoad = null;
|
|
739
|
+
resolve(result);
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
runtime.cancelLoadTimer = timers.after(resolved.timeouts.load, () => {
|
|
743
|
+
const error = new AdsError('load_timeout', {
|
|
744
|
+
operation: 'load',
|
|
745
|
+
placementKey: key,
|
|
746
|
+
format,
|
|
747
|
+
message: `load timed out after ${resolved.timeouts.load}ms`,
|
|
748
|
+
});
|
|
749
|
+
events.emit({
|
|
750
|
+
type: 'load_failed',
|
|
751
|
+
timestamp: now(),
|
|
752
|
+
placementKey: key,
|
|
753
|
+
format,
|
|
754
|
+
correlationId,
|
|
755
|
+
error,
|
|
756
|
+
});
|
|
757
|
+
dispatchPlacement(key, { type: 'LOAD_FAILED', error });
|
|
758
|
+
releasePlacementResources(key);
|
|
759
|
+
finish({ status: 'timeout' });
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
engine
|
|
763
|
+
.createFullScreen(format, adUnitId)
|
|
764
|
+
.then(handle => {
|
|
765
|
+
if (disposed || settled) {
|
|
766
|
+
engine.dispose(handle);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
runtime.handle = handle;
|
|
770
|
+
runtime.unsubscribeEngine = engine.subscribe(handle, event => {
|
|
771
|
+
if (event.type === 'loaded') {
|
|
772
|
+
dispatchPlacement(key, { type: 'LOADED', atMs: now() });
|
|
773
|
+
events.emit({
|
|
774
|
+
type: 'loaded',
|
|
775
|
+
timestamp: now(),
|
|
776
|
+
placementKey: key,
|
|
777
|
+
format,
|
|
778
|
+
correlationId,
|
|
779
|
+
durationMs: now() - startedAt,
|
|
780
|
+
});
|
|
781
|
+
finish({ status: 'loaded', correlationId });
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
if (event.type === 'failed') {
|
|
785
|
+
const normalized = normalizeProviderError(event.cause, {
|
|
786
|
+
operation: 'load',
|
|
787
|
+
placementKey: key,
|
|
788
|
+
format,
|
|
789
|
+
});
|
|
790
|
+
events.emit({
|
|
791
|
+
type: 'load_failed',
|
|
792
|
+
timestamp: now(),
|
|
793
|
+
placementKey: key,
|
|
794
|
+
format,
|
|
795
|
+
correlationId,
|
|
796
|
+
error: normalized,
|
|
797
|
+
});
|
|
798
|
+
dispatchPlacement(key, { type: 'LOAD_FAILED', error: normalized });
|
|
799
|
+
releasePlacementResources(key);
|
|
800
|
+
finish(
|
|
801
|
+
normalized.code === 'no_fill'
|
|
802
|
+
? { status: 'no_fill' }
|
|
803
|
+
: { status: 'failed', error: normalized },
|
|
804
|
+
);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
handleEngineEvent(key, definition, event);
|
|
808
|
+
});
|
|
809
|
+
})
|
|
810
|
+
.catch(error => {
|
|
811
|
+
const normalized = normalizeProviderError(error, {
|
|
812
|
+
operation: 'load',
|
|
813
|
+
placementKey: key,
|
|
814
|
+
format,
|
|
815
|
+
});
|
|
816
|
+
events.emit({
|
|
817
|
+
type: 'load_failed',
|
|
818
|
+
timestamp: now(),
|
|
819
|
+
placementKey: key,
|
|
820
|
+
format,
|
|
821
|
+
correlationId,
|
|
822
|
+
error: normalized,
|
|
823
|
+
});
|
|
824
|
+
dispatchPlacement(key, { type: 'LOAD_FAILED', error: normalized });
|
|
825
|
+
finish(
|
|
826
|
+
normalized.code === 'no_fill'
|
|
827
|
+
? { status: 'no_fill' }
|
|
828
|
+
: { status: 'failed', error: normalized },
|
|
829
|
+
);
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* One load, plus however many bounded retries the policy allows (FR-134).
|
|
836
|
+
*
|
|
837
|
+
* Off unless configured, so an upgraded 0.1.0 consumer goes through here once
|
|
838
|
+
* and issues exactly the requests it always did. When on, only errors already
|
|
839
|
+
* flagged `retryable` are retried — a configuration or consent failure is a
|
|
840
|
+
* state another attempt cannot change, and retrying it would spend a billable
|
|
841
|
+
* request on a certainty (FR-135).
|
|
842
|
+
*
|
|
843
|
+
* Every attempt is emitted before it is waited on, carrying the cycle's
|
|
844
|
+
* correlation id, so the event stream can be reconciled against a bill
|
|
845
|
+
* without inferring anything (FR-138).
|
|
846
|
+
*/
|
|
847
|
+
async function loadWithRetry(
|
|
848
|
+
key: string,
|
|
849
|
+
definition: PlacementDefinition,
|
|
850
|
+
adUnitId: string,
|
|
851
|
+
correlationId: string,
|
|
852
|
+
): Promise<LoadResult> {
|
|
853
|
+
const runtime = placements.get(key)!;
|
|
854
|
+
const policy = resolved.retryByKey.get(key) ?? null;
|
|
855
|
+
|
|
856
|
+
for (;;) {
|
|
857
|
+
const result = await loadFullScreen(key, definition, adUnitId, correlationId);
|
|
858
|
+
if (result.status === 'loaded' || disposed) {
|
|
859
|
+
return result;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const error =
|
|
863
|
+
result.status === 'failed'
|
|
864
|
+
? result.error
|
|
865
|
+
: result.status === 'no_fill'
|
|
866
|
+
? new AdsError('no_fill', { operation: 'load', placementKey: key })
|
|
867
|
+
: result.status === 'timeout'
|
|
868
|
+
? new AdsError('load_timeout', { operation: 'load', placementKey: key })
|
|
869
|
+
: null;
|
|
870
|
+
|
|
871
|
+
// A refusal — disabled, undecided, blocked, unavailable — is not a
|
|
872
|
+
// failure to retry. Nothing was requested, and nothing about asking
|
|
873
|
+
// again would change that.
|
|
874
|
+
if (!error || !error.retryable) {
|
|
875
|
+
return result;
|
|
876
|
+
}
|
|
877
|
+
if (!canRetry(policy, runtime.retryAttempts) || !permitsRequests(enablement)) {
|
|
878
|
+
return result;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
runtime.retryAttempts += 1;
|
|
882
|
+
const delayMs = delayForAttempt(policy!, runtime.retryAttempts);
|
|
883
|
+
events.emit({
|
|
884
|
+
type: 'retry_scheduled',
|
|
885
|
+
timestamp: now(),
|
|
886
|
+
placementKey: key,
|
|
887
|
+
format: definition.format,
|
|
888
|
+
correlationId,
|
|
889
|
+
attempt: runtime.retryAttempts,
|
|
890
|
+
delayMs,
|
|
891
|
+
error,
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
const waited = await new Promise<boolean>(resolve => {
|
|
895
|
+
runtime.cancelRetry = timers.after(delayMs, () => {
|
|
896
|
+
runtime.cancelRetry = null;
|
|
897
|
+
resolve(true);
|
|
898
|
+
});
|
|
899
|
+
const cancel = runtime.cancelRetry;
|
|
900
|
+
// Cancellation replaces the handle, so a disable or dispose that
|
|
901
|
+
// clears it resolves this wait as "do not continue" rather than
|
|
902
|
+
// leaving a promise nobody will settle.
|
|
903
|
+
runtime.cancelRetry = () => {
|
|
904
|
+
cancel?.();
|
|
905
|
+
runtime.cancelRetry = null;
|
|
906
|
+
resolve(false);
|
|
907
|
+
};
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
if (!waited || disposed || !permitsRequests(enablement)) {
|
|
911
|
+
return result;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
dispatchPlacement(key, { type: 'LOAD' });
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Applies a consent outcome to the gate without a second initialization.
|
|
920
|
+
*
|
|
921
|
+
* This is FR-105's "the app does not wire it by hand" in one function. When
|
|
922
|
+
* consent opens the gate and the instance was blocked, it initializes — and
|
|
923
|
+
* because `initialize` is idempotent and shares one in-flight operation, the
|
|
924
|
+
* total native initialization count across a whole consent flow stays at one
|
|
925
|
+
* (FR-022, SC-102). When consent closes the gate, requests stop and every
|
|
926
|
+
* pending retry is cancelled with them (FR-137).
|
|
927
|
+
*/
|
|
928
|
+
async function reevaluateAfterConsent(): Promise<void> {
|
|
929
|
+
if (disposed || !currentPrivacy) {
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
const decision = currentGate();
|
|
933
|
+
if (!decision.allowed) {
|
|
934
|
+
cancelAllRetries();
|
|
935
|
+
if (decision.kind === 'blocked' && init.status !== 'ready') {
|
|
936
|
+
setInit(initReducer(init, { type: 'BLOCK', reason: decision.reason }));
|
|
937
|
+
}
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (init.status === 'ready' || init.status === 'initializing') {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
await instance.initialize(currentPrivacy);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const instance: AdsInstance = {
|
|
947
|
+
async initialize(privacy) {
|
|
948
|
+
if (disposed) {
|
|
949
|
+
return { status: 'unavailable', reason: 'provider_missing' };
|
|
950
|
+
}
|
|
951
|
+
currentPrivacy = privacy;
|
|
952
|
+
|
|
953
|
+
// Availability first, then enablement, then consent — the one order,
|
|
954
|
+
// evaluated in the one place (FR-140).
|
|
955
|
+
const decision = currentGate();
|
|
956
|
+
if (!decision.allowed && decision.kind === 'unavailable') {
|
|
957
|
+
setInit(initReducer(init, { type: 'UNAVAILABLE', reason: decision.reason }));
|
|
958
|
+
const result = { status: 'unavailable', reason: decision.reason } as const;
|
|
959
|
+
emitRefusal('initialize', result);
|
|
960
|
+
return result;
|
|
961
|
+
}
|
|
962
|
+
if (!decision.allowed && decision.kind === 'disabled') {
|
|
963
|
+
setInit(initReducer(init, { type: 'DISABLE' }));
|
|
964
|
+
const result = { status: 'disabled' } as const;
|
|
965
|
+
emitRefusal('initialize', result);
|
|
966
|
+
return result;
|
|
967
|
+
}
|
|
968
|
+
if (!decision.allowed && decision.kind === 'undecided') {
|
|
969
|
+
// FR-115. Nothing provider-facing happens, exactly as when
|
|
970
|
+
// disabled — but the status the app observes says `undecided`, so
|
|
971
|
+
// a banner host can hold its space instead of reclaiming it.
|
|
972
|
+
setInit(initReducer(init, { type: 'UNDECIDE' }));
|
|
973
|
+
armUndecidedWarning();
|
|
974
|
+
const result = { status: 'undecided' } as const;
|
|
975
|
+
emitRefusal('initialize', result);
|
|
976
|
+
return result;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const gate = evaluateGate(privacy);
|
|
980
|
+
if (!gate.allowed) {
|
|
981
|
+
// FR-017. Nothing provider-facing happens here at all — not a
|
|
982
|
+
// configuration call, not an initialization. The engine is untouched.
|
|
983
|
+
setInit(initReducer(init, { type: 'BLOCK', reason: gate.reason }));
|
|
984
|
+
const result = { status: 'blocked', reason: gate.reason } as const;
|
|
985
|
+
emitRefusal('initialize', result);
|
|
986
|
+
return result;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (init.status === 'ready') {
|
|
990
|
+
return { status: 'ready' };
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// FR-022, SC-005. Racing callers share one operation, so the provider
|
|
994
|
+
// initializes exactly once however many arrive.
|
|
995
|
+
if (inFlightInit) {
|
|
996
|
+
return inFlightInit;
|
|
997
|
+
}
|
|
998
|
+
if (!canStart(init.status)) {
|
|
999
|
+
return { status: 'ready' };
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
setInit(initReducer(init, { type: 'START' }));
|
|
1003
|
+
inFlightInit = doInitialize(privacy);
|
|
1004
|
+
return inFlightInit;
|
|
1005
|
+
},
|
|
1006
|
+
|
|
1007
|
+
async updatePrivacy(privacy) {
|
|
1008
|
+
if (disposed) {
|
|
1009
|
+
return { status: 'disabled' };
|
|
1010
|
+
}
|
|
1011
|
+
if (enablement === 'undecided') {
|
|
1012
|
+
return { status: 'undecided' };
|
|
1013
|
+
}
|
|
1014
|
+
if (enablement === 'disabled') {
|
|
1015
|
+
return { status: 'disabled' };
|
|
1016
|
+
}
|
|
1017
|
+
if (!engine.isAvailable()) {
|
|
1018
|
+
return { status: 'unavailable', reason: 'unsupported_platform' };
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// FR-020: a flag the provider only honors at initialization cannot be
|
|
1022
|
+
// applied now. Say so, rather than accepting it and running half-old.
|
|
1023
|
+
if (requiresReinitialization(appliedPrivacy, privacy)) {
|
|
1024
|
+
return { status: 'requires_reinitialization' };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
currentPrivacy = privacy;
|
|
1028
|
+
const gate = evaluateGate(privacy);
|
|
1029
|
+
if (!gate.allowed) {
|
|
1030
|
+
setInit(initReducer(init, { type: 'BLOCK', reason: gate.reason }));
|
|
1031
|
+
return { status: 'blocked', reason: gate.reason };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// FR-019: resolving consent after a block must not produce a second
|
|
1035
|
+
// native initialization.
|
|
1036
|
+
if (init.status === 'ready') {
|
|
1037
|
+
return { status: 'applied' };
|
|
1038
|
+
}
|
|
1039
|
+
const result = await instance.initialize(privacy);
|
|
1040
|
+
return result.status === 'ready'
|
|
1041
|
+
? { status: 'applied' }
|
|
1042
|
+
: result.status === 'blocked'
|
|
1043
|
+
? { status: 'blocked', reason: result.reason }
|
|
1044
|
+
: { status: 'applied' };
|
|
1045
|
+
},
|
|
1046
|
+
|
|
1047
|
+
setEnabled(next) {
|
|
1048
|
+
return instance.setEnablement(next ? 'enabled' : 'disabled');
|
|
1049
|
+
},
|
|
1050
|
+
|
|
1051
|
+
setEnablement(next) {
|
|
1052
|
+
if (disposed) {
|
|
1053
|
+
return { status: 'unavailable', reason: 'provider_missing' };
|
|
1054
|
+
}
|
|
1055
|
+
if (enablement === next) {
|
|
1056
|
+
return { status: 'unchanged' };
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const previous = enablement;
|
|
1060
|
+
enablement = next;
|
|
1061
|
+
|
|
1062
|
+
if (next === 'disabled' || next === 'undecided') {
|
|
1063
|
+
// FR-037 and FR-125: new work is prevented and in-flight resources
|
|
1064
|
+
// released deterministically, and moving to `undecided` from
|
|
1065
|
+
// `enabled` follows the same rules as disabling. An ad already on
|
|
1066
|
+
// screen is left alone — the provider offers no safe way to close
|
|
1067
|
+
// it, and yanking it would be worse than letting it finish.
|
|
1068
|
+
cancelAllRetries();
|
|
1069
|
+
for (const [key, runtime] of placements) {
|
|
1070
|
+
if (runtime.snapshot.state === 'showing') {
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
releasePlacementResources(key);
|
|
1074
|
+
dispatchPlacement(key, { type: 'RESET' });
|
|
1075
|
+
}
|
|
1076
|
+
setInit(initReducer(init, { type: next === 'disabled' ? 'DISABLE' : 'UNDECIDE' }));
|
|
1077
|
+
if (next === 'undecided') {
|
|
1078
|
+
armUndecidedWarning();
|
|
1079
|
+
return { status: 'undecided' };
|
|
1080
|
+
}
|
|
1081
|
+
disarmUndecidedWarning();
|
|
1082
|
+
return { status: 'disabled' };
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Turning ads on. This is the path 0.1.0 got wrong: it dropped the
|
|
1086
|
+
// status to `not_started` and returned nothing, so an app whose user
|
|
1087
|
+
// lost their entitlement turned ads back on and then had every load
|
|
1088
|
+
// silently refused (FR-121).
|
|
1089
|
+
disarmUndecidedWarning();
|
|
1090
|
+
const resumable = canResume(init.resumeTo);
|
|
1091
|
+
setInit(initReducer(init, { type: 'ENABLE' }));
|
|
1092
|
+
|
|
1093
|
+
if (init.status === 'unavailable') {
|
|
1094
|
+
return { status: 'unavailable', reason: init.unavailableReason ?? 'unsupported_platform' };
|
|
1095
|
+
}
|
|
1096
|
+
if (resumable && init.status === 'ready') {
|
|
1097
|
+
// The provider was never un-initialized, so there is genuinely
|
|
1098
|
+
// nothing to redo — and FR-022 forbids initializing it twice.
|
|
1099
|
+
return { status: 'resumed', initStatus: init.status };
|
|
1100
|
+
}
|
|
1101
|
+
if (previous === 'undecided' && init.status === 'ready') {
|
|
1102
|
+
return { status: 'resumed', initStatus: init.status };
|
|
1103
|
+
}
|
|
1104
|
+
// Said out loud rather than left for the app to discover from a
|
|
1105
|
+
// refused load. The package will not initialize implicitly: that would
|
|
1106
|
+
// hide an asynchronous, billable operation behind a setter the caller
|
|
1107
|
+
// may not be awaiting.
|
|
1108
|
+
return { status: 'requires_initialization' };
|
|
1109
|
+
},
|
|
1110
|
+
|
|
1111
|
+
setAppActive(active) {
|
|
1112
|
+
if (disposed) {
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
appActive = active;
|
|
1116
|
+
if (!active) {
|
|
1117
|
+
// Arming happens when the provider reports an ad opened, not here:
|
|
1118
|
+
// this event can be delivered late enough that the ad is already
|
|
1119
|
+
// gone. All this adds is the browser case, where a background
|
|
1120
|
+
// closely follows a click (FR-128).
|
|
1121
|
+
adReturn.noteBackground(now());
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
adReturn.noteForeground(now());
|
|
1125
|
+
// FR-034: on return to the foreground, re-evaluate what was loaded.
|
|
1126
|
+
// An ad that went stale while the app was away must stop reporting
|
|
1127
|
+
// `ready` before the app offers a button that would fail on tap.
|
|
1128
|
+
const current = now();
|
|
1129
|
+
for (const [key, runtime] of placements) {
|
|
1130
|
+
const definition = resolved.placements.get(key);
|
|
1131
|
+
if (
|
|
1132
|
+
runtime.snapshot.state === 'ready' &&
|
|
1133
|
+
runtime.snapshot.loadedAtMs !== null &&
|
|
1134
|
+
definition !== undefined &&
|
|
1135
|
+
isStale(runtime.snapshot.loadedAtMs, current, validityFor(definition.format))
|
|
1136
|
+
) {
|
|
1137
|
+
releasePlacementResources(key);
|
|
1138
|
+
dispatchPlacement(key, { type: 'EXPIRE' });
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
},
|
|
1142
|
+
|
|
1143
|
+
getStatus() {
|
|
1144
|
+
return init.status;
|
|
1145
|
+
},
|
|
1146
|
+
|
|
1147
|
+
subscribeStatus(listener) {
|
|
1148
|
+
statusListeners.add(listener);
|
|
1149
|
+
let active = true;
|
|
1150
|
+
return () => {
|
|
1151
|
+
if (!active) {
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
active = false;
|
|
1155
|
+
statusListeners.delete(listener);
|
|
1156
|
+
};
|
|
1157
|
+
},
|
|
1158
|
+
|
|
1159
|
+
async load(placementKey) {
|
|
1160
|
+
if (disposed) {
|
|
1161
|
+
return { status: 'unavailable', reason: 'provider_missing' };
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const definition = resolved.placements.get(placementKey);
|
|
1165
|
+
if (!definition) {
|
|
1166
|
+
return {
|
|
1167
|
+
status: 'failed',
|
|
1168
|
+
error: new AdsError('placement_not_found', {
|
|
1169
|
+
operation: 'load',
|
|
1170
|
+
placementKey,
|
|
1171
|
+
message: `unknown placement "${placementKey}"; configured keys are ${[...resolved.placements.keys()].join(', ')}`,
|
|
1172
|
+
}),
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const gate = gateCheck();
|
|
1177
|
+
if (!gate.ok) {
|
|
1178
|
+
emitRefusal('load', gate.result, placementKey, definition.format);
|
|
1179
|
+
return gate.result;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
if (!canRequest(init.status)) {
|
|
1183
|
+
return {
|
|
1184
|
+
status: 'failed',
|
|
1185
|
+
error: new AdsError('not_ready', {
|
|
1186
|
+
operation: 'load',
|
|
1187
|
+
placementKey,
|
|
1188
|
+
format: definition.format,
|
|
1189
|
+
message: 'initialize() must complete before loading',
|
|
1190
|
+
}),
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
if (definition.format === 'banner') {
|
|
1195
|
+
return {
|
|
1196
|
+
status: 'failed',
|
|
1197
|
+
error: new AdsError('format_mismatch', {
|
|
1198
|
+
operation: 'load',
|
|
1199
|
+
placementKey,
|
|
1200
|
+
format: definition.format,
|
|
1201
|
+
message: `"${placementKey}" is a banner; banners render through <AdsBanner> rather than load()`,
|
|
1202
|
+
}),
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const runtime = placements.get(placementKey)!;
|
|
1207
|
+
|
|
1208
|
+
// FR-029, SC-002's sibling guarantee: a repeat load while one is in
|
|
1209
|
+
// flight joins it instead of creating a second provider request.
|
|
1210
|
+
if (runtime.inFlightLoad) {
|
|
1211
|
+
return runtime.inFlightLoad;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// FR-140. Checked after the in-flight join so a caller that merely
|
|
1215
|
+
// arrived twice for one cycle is answered by the join, not charged a
|
|
1216
|
+
// throttle slot — and before the correlation id and `load_started`,
|
|
1217
|
+
// because a refused request is not a cycle and must not look like one
|
|
1218
|
+
// in the event stream.
|
|
1219
|
+
const throttled = checkThrottle(placementKey, definition.format);
|
|
1220
|
+
if (throttled) {
|
|
1221
|
+
return throttled;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
if (runtime.snapshot.state === 'showing') {
|
|
1225
|
+
return { status: 'failed', error: new AdsError('busy', { operation: 'load', placementKey }) };
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
if (!canLoad(runtime.snapshot.state)) {
|
|
1229
|
+
return {
|
|
1230
|
+
status: 'failed',
|
|
1231
|
+
error: new AdsError('not_ready', { operation: 'load', placementKey }),
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
releasePlacementResources(placementKey);
|
|
1236
|
+
|
|
1237
|
+
const correlationId = correlation.next();
|
|
1238
|
+
runtime.correlationId = correlationId;
|
|
1239
|
+
runtime.dismissed = false;
|
|
1240
|
+
dispatchPlacement(placementKey, { type: 'LOAD' });
|
|
1241
|
+
setPlacement(placementKey, { ...runtime.snapshot, correlationId });
|
|
1242
|
+
events.emit({
|
|
1243
|
+
type: 'load_started',
|
|
1244
|
+
timestamp: now(),
|
|
1245
|
+
placementKey,
|
|
1246
|
+
format: definition.format,
|
|
1247
|
+
correlationId,
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
const adUnitId = resolved.catalog.byKey.get(placementKey)!;
|
|
1251
|
+
runtime.retryAttempts = 0;
|
|
1252
|
+
const promise = loadWithRetry(placementKey, definition, adUnitId, correlationId);
|
|
1253
|
+
runtime.inFlightLoad = promise;
|
|
1254
|
+
return promise;
|
|
1255
|
+
},
|
|
1256
|
+
|
|
1257
|
+
async show(placementKey) {
|
|
1258
|
+
if (disposed) {
|
|
1259
|
+
return { status: 'unavailable', reason: 'provider_missing' };
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const definition = resolved.placements.get(placementKey);
|
|
1263
|
+
if (!definition) {
|
|
1264
|
+
return {
|
|
1265
|
+
status: 'failed',
|
|
1266
|
+
error: new AdsError('placement_not_found', {
|
|
1267
|
+
operation: 'show',
|
|
1268
|
+
placementKey,
|
|
1269
|
+
message: `unknown placement "${placementKey}"; configured keys are ${[...resolved.placements.keys()].join(', ')}`,
|
|
1270
|
+
}),
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
if (!isFullScreenFormat(definition.format)) {
|
|
1275
|
+
return {
|
|
1276
|
+
status: 'failed',
|
|
1277
|
+
error: new AdsError('format_mismatch', {
|
|
1278
|
+
operation: 'show',
|
|
1279
|
+
placementKey,
|
|
1280
|
+
format: definition.format,
|
|
1281
|
+
message: `"${placementKey}" is a banner; banners are rendered, not shown`,
|
|
1282
|
+
}),
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const gate = gateCheck();
|
|
1287
|
+
if (!gate.ok) {
|
|
1288
|
+
emitRefusal('show', gate.result, placementKey, definition.format);
|
|
1289
|
+
return gate.result;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// FR-028: never open an ad while the app is not in front of the user.
|
|
1293
|
+
if (!appActive) {
|
|
1294
|
+
return { status: 'app_inactive' };
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// FR-128. Only app-open is refused: the user just came back from an ad
|
|
1298
|
+
// or from a browser an ad opened, and app-open is the format that
|
|
1299
|
+
// would fire on exactly that return. An interstitial at the same
|
|
1300
|
+
// moment is the app's own deliberate choice, made somewhere the
|
|
1301
|
+
// package cannot see, so refusing it would be policy rather than
|
|
1302
|
+
// capability.
|
|
1303
|
+
if (definition.format === 'app_open' && adReturn.isRefusing(now())) {
|
|
1304
|
+
return { status: 'ad_originated_return' };
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const runtime = placements.get(placementKey)!;
|
|
1308
|
+
|
|
1309
|
+
if (runtime.snapshot.state === 'consumed') {
|
|
1310
|
+
return { status: 'consumed' };
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// FR-034: a stale ad is not shown and is not reported ready.
|
|
1314
|
+
if (
|
|
1315
|
+
runtime.snapshot.state === 'ready' &&
|
|
1316
|
+
runtime.snapshot.loadedAtMs !== null &&
|
|
1317
|
+
isStale(runtime.snapshot.loadedAtMs, now(), validityFor(definition.format))
|
|
1318
|
+
) {
|
|
1319
|
+
releasePlacementResources(placementKey);
|
|
1320
|
+
dispatchPlacement(placementKey, { type: 'EXPIRE' });
|
|
1321
|
+
return { status: 'not_ready' };
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
if (runtime.snapshot.state !== 'ready' || !runtime.handle) {
|
|
1325
|
+
return { status: 'not_ready' };
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// FR-030. Acquired before anything else can open a screen, and never
|
|
1329
|
+
// queued: a refused show is refused now, not deferred to a moment the
|
|
1330
|
+
// app never chose.
|
|
1331
|
+
if (!lock.acquire(placementKey)) {
|
|
1332
|
+
return { status: 'busy' };
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const correlationId = runtime.correlationId ?? correlation.next();
|
|
1336
|
+
runtime.rewarded = definition.format === 'rewarded' ? createRewardedSession() : null;
|
|
1337
|
+
dispatchPlacement(placementKey, { type: 'SHOW' });
|
|
1338
|
+
|
|
1339
|
+
let settled = false;
|
|
1340
|
+
const showTimeout = new Promise<ShowResult>(resolve => {
|
|
1341
|
+
runtime.cancelShowTimer = timers.after(resolved.timeouts.show, () => {
|
|
1342
|
+
if (settled) {
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
settled = true;
|
|
1346
|
+
const error = new AdsError('show_timeout', {
|
|
1347
|
+
operation: 'show',
|
|
1348
|
+
placementKey,
|
|
1349
|
+
format: definition.format,
|
|
1350
|
+
message: `show timed out after ${resolved.timeouts.show}ms`,
|
|
1351
|
+
});
|
|
1352
|
+
lock.release(placementKey);
|
|
1353
|
+
events.emit({
|
|
1354
|
+
type: 'display_failed',
|
|
1355
|
+
timestamp: now(),
|
|
1356
|
+
placementKey,
|
|
1357
|
+
format: definition.format,
|
|
1358
|
+
correlationId,
|
|
1359
|
+
error,
|
|
1360
|
+
});
|
|
1361
|
+
dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error });
|
|
1362
|
+
releasePlacementResources(placementKey);
|
|
1363
|
+
resolve({ status: 'timeout' });
|
|
1364
|
+
});
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
const attempt = engine
|
|
1368
|
+
.show(runtime.handle)
|
|
1369
|
+
.then<ShowResult>(() => {
|
|
1370
|
+
settled = true;
|
|
1371
|
+
runtime.cancelShowTimer?.();
|
|
1372
|
+
runtime.cancelShowTimer = null;
|
|
1373
|
+
return { status: 'shown', correlationId };
|
|
1374
|
+
})
|
|
1375
|
+
.catch<ShowResult>(error => {
|
|
1376
|
+
settled = true;
|
|
1377
|
+
runtime.cancelShowTimer?.();
|
|
1378
|
+
runtime.cancelShowTimer = null;
|
|
1379
|
+
const normalized = normalizeProviderError(error, {
|
|
1380
|
+
operation: 'show',
|
|
1381
|
+
placementKey,
|
|
1382
|
+
format: definition.format,
|
|
1383
|
+
});
|
|
1384
|
+
lock.release(placementKey);
|
|
1385
|
+
events.emit({
|
|
1386
|
+
type: 'display_failed',
|
|
1387
|
+
timestamp: now(),
|
|
1388
|
+
placementKey,
|
|
1389
|
+
format: definition.format,
|
|
1390
|
+
correlationId,
|
|
1391
|
+
error: normalized,
|
|
1392
|
+
});
|
|
1393
|
+
dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error: normalized });
|
|
1394
|
+
releasePlacementResources(placementKey);
|
|
1395
|
+
return { status: 'failed', error: normalized };
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
return Promise.race([attempt, showTimeout]);
|
|
1399
|
+
},
|
|
1400
|
+
|
|
1401
|
+
getPlacementState(placementKey) {
|
|
1402
|
+
if (!resolved.placements.has(placementKey)) {
|
|
1403
|
+
return 'unavailable';
|
|
1404
|
+
}
|
|
1405
|
+
if (!engine.isAvailable() || enablement === 'disabled') {
|
|
1406
|
+
return 'unavailable';
|
|
1407
|
+
}
|
|
1408
|
+
// Reported distinctly from `unavailable` so the app can choose to hold
|
|
1409
|
+
// the banner's space rather than reclaim it. That choice is the entire
|
|
1410
|
+
// reason the third state is worth having (FR-116).
|
|
1411
|
+
if (enablement === 'undecided') {
|
|
1412
|
+
return 'undecided';
|
|
1413
|
+
}
|
|
1414
|
+
return placements.get(placementKey)?.snapshot.state ?? 'idle';
|
|
1415
|
+
},
|
|
1416
|
+
|
|
1417
|
+
subscribePlacement(key, listener) {
|
|
1418
|
+
const runtime = placements.get(key);
|
|
1419
|
+
if (!runtime) {
|
|
1420
|
+
return () => undefined;
|
|
1421
|
+
}
|
|
1422
|
+
runtime.listeners.add(listener);
|
|
1423
|
+
let active = true;
|
|
1424
|
+
return () => {
|
|
1425
|
+
if (!active) {
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
active = false;
|
|
1429
|
+
runtime.listeners.delete(listener);
|
|
1430
|
+
};
|
|
1431
|
+
},
|
|
1432
|
+
|
|
1433
|
+
subscribeEvents(listener) {
|
|
1434
|
+
return events.subscribe(listener);
|
|
1435
|
+
},
|
|
1436
|
+
|
|
1437
|
+
getSubstitutedPlacements() {
|
|
1438
|
+
return resolved.catalog.substitutedKeys;
|
|
1439
|
+
},
|
|
1440
|
+
|
|
1441
|
+
getIgnoredConsentTools() {
|
|
1442
|
+
return resolved.ignoredConsentTools;
|
|
1443
|
+
},
|
|
1444
|
+
|
|
1445
|
+
consent: {
|
|
1446
|
+
async requestInfoUpdate() {
|
|
1447
|
+
if (disposed) {
|
|
1448
|
+
return consent;
|
|
1449
|
+
}
|
|
1450
|
+
// Marks the flow as in use. Until this is called the gate sees no
|
|
1451
|
+
// consent input at all, which is how an app that supplies its own
|
|
1452
|
+
// gate keeps 0.1.0 behavior without opting out of anything
|
|
1453
|
+
// (FR-102).
|
|
1454
|
+
consentStarted = true;
|
|
1455
|
+
if (resolved.consentTesting?.resetOnStart) {
|
|
1456
|
+
try {
|
|
1457
|
+
engine.consent.reset();
|
|
1458
|
+
} catch {
|
|
1459
|
+
// A reset that fails must not abort the update; the flow
|
|
1460
|
+
// continues against whatever state is stored.
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
const next = await runConsentStep(
|
|
1464
|
+
() =>
|
|
1465
|
+
engine.consent.requestInfoUpdate(
|
|
1466
|
+
buildConsentOptions(
|
|
1467
|
+
resolved.consentTesting,
|
|
1468
|
+
currentPrivacy?.underAgeOfConsent,
|
|
1469
|
+
resolved.testDeviceIds,
|
|
1470
|
+
),
|
|
1471
|
+
),
|
|
1472
|
+
'consent information update failed',
|
|
1473
|
+
);
|
|
1474
|
+
if (disposed) {
|
|
1475
|
+
return consent;
|
|
1476
|
+
}
|
|
1477
|
+
setConsent(next);
|
|
1478
|
+
await reevaluateAfterConsent();
|
|
1479
|
+
return next;
|
|
1480
|
+
},
|
|
1481
|
+
|
|
1482
|
+
async showFormIfRequired() {
|
|
1483
|
+
if (disposed) {
|
|
1484
|
+
return consent;
|
|
1485
|
+
}
|
|
1486
|
+
consentStarted = true;
|
|
1487
|
+
if (!needsForm(consent)) {
|
|
1488
|
+
// Nothing to present. Not an error: a region that does not
|
|
1489
|
+
// require consent is the normal case, and showing a form there
|
|
1490
|
+
// is exactly what FR-107 forbids the package from deciding.
|
|
1491
|
+
return consent;
|
|
1492
|
+
}
|
|
1493
|
+
const next = await runConsentStep(
|
|
1494
|
+
() => engine.consent.showFormIfRequired(),
|
|
1495
|
+
'consent form failed',
|
|
1496
|
+
);
|
|
1497
|
+
if (disposed) {
|
|
1498
|
+
return consent;
|
|
1499
|
+
}
|
|
1500
|
+
setConsent(next);
|
|
1501
|
+
await reevaluateAfterConsent();
|
|
1502
|
+
return next;
|
|
1503
|
+
},
|
|
1504
|
+
|
|
1505
|
+
getState() {
|
|
1506
|
+
return consent;
|
|
1507
|
+
},
|
|
1508
|
+
|
|
1509
|
+
subscribe(listener) {
|
|
1510
|
+
consentListeners.add(listener);
|
|
1511
|
+
let active = true;
|
|
1512
|
+
return () => {
|
|
1513
|
+
if (!active) {
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
active = false;
|
|
1517
|
+
consentListeners.delete(listener);
|
|
1518
|
+
};
|
|
1519
|
+
},
|
|
1520
|
+
|
|
1521
|
+
async reset() {
|
|
1522
|
+
if (disposed) {
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
// Config validation already stripped the testing tools in
|
|
1526
|
+
// production, so this is inert there without a check here (FR-113).
|
|
1527
|
+
if (!resolved.consentTesting) {
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
try {
|
|
1531
|
+
engine.consent.reset();
|
|
1532
|
+
} catch {
|
|
1533
|
+
// Nothing to report: the stored state is the provider's, and a
|
|
1534
|
+
// failed reset leaves it as it was.
|
|
1535
|
+
}
|
|
1536
|
+
setConsent(INITIAL_CONSENT);
|
|
1537
|
+
},
|
|
1538
|
+
},
|
|
1539
|
+
|
|
1540
|
+
internals: {
|
|
1541
|
+
resolveBanner(placementKey) {
|
|
1542
|
+
const definition = resolved.placements.get(placementKey);
|
|
1543
|
+
if (!definition) {
|
|
1544
|
+
return {
|
|
1545
|
+
ok: false,
|
|
1546
|
+
result: {
|
|
1547
|
+
status: 'failed',
|
|
1548
|
+
error: new AdsError('placement_not_found', {
|
|
1549
|
+
operation: 'load',
|
|
1550
|
+
placementKey,
|
|
1551
|
+
message: `unknown placement "${placementKey}"`,
|
|
1552
|
+
}),
|
|
1553
|
+
},
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
if (definition.format !== 'banner') {
|
|
1557
|
+
return {
|
|
1558
|
+
ok: false,
|
|
1559
|
+
result: {
|
|
1560
|
+
status: 'failed',
|
|
1561
|
+
error: new AdsError('format_mismatch', {
|
|
1562
|
+
operation: 'load',
|
|
1563
|
+
placementKey,
|
|
1564
|
+
format: definition.format,
|
|
1565
|
+
message: `"${placementKey}" is not a banner placement`,
|
|
1566
|
+
}),
|
|
1567
|
+
},
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
const gate = gateCheck();
|
|
1571
|
+
if (!gate.ok) {
|
|
1572
|
+
return { ok: false, result: gate.result };
|
|
1573
|
+
}
|
|
1574
|
+
if (!canRequest(init.status)) {
|
|
1575
|
+
return { ok: false, result: { status: 'blocked', reason: 'consent_required' } };
|
|
1576
|
+
}
|
|
1577
|
+
// The throttle is deliberately **not** consulted here. It cannot be
|
|
1578
|
+
// decided from configuration alone: a fresh mount must be throttled
|
|
1579
|
+
// (that is the navigation burst the feature exists for) while a width
|
|
1580
|
+
// re-adaptation of an already-mounted banner must not be (that is
|
|
1581
|
+
// rotation, and refusing it would leave the user with no banner for a
|
|
1582
|
+
// minute). Only the host knows which it is, so the host calls
|
|
1583
|
+
// `commitBannerRequest` (FR-140).
|
|
1584
|
+
return {
|
|
1585
|
+
ok: true,
|
|
1586
|
+
adUnitId: resolved.catalog.byKey.get(placementKey)!,
|
|
1587
|
+
correlationId: correlation.next(),
|
|
1588
|
+
};
|
|
1589
|
+
},
|
|
1590
|
+
commitBannerRequest(placementKey, correlationId) {
|
|
1591
|
+
if (!resolved.placements.has(placementKey)) {
|
|
1592
|
+
return null;
|
|
1593
|
+
}
|
|
1594
|
+
const minRequestIntervalMs =
|
|
1595
|
+
resolved.minRequestIntervalByKey.get(placementKey) ?? 0;
|
|
1596
|
+
const decision = requestThrottle.tryAcquire(
|
|
1597
|
+
placementKey,
|
|
1598
|
+
minRequestIntervalMs,
|
|
1599
|
+
correlationId,
|
|
1600
|
+
);
|
|
1601
|
+
// Deliberately silent: the host calls this during render, because the
|
|
1602
|
+
// provider component requests as part of that same render and an effect
|
|
1603
|
+
// would be too late to stop it. Emitting an event or setting placement
|
|
1604
|
+
// state here would mean mutating during another component's render, so
|
|
1605
|
+
// the refusal is only returned — the host reports it from its effect.
|
|
1606
|
+
return decision.allowed
|
|
1607
|
+
? null
|
|
1608
|
+
: { status: 'throttled', retryAfterMs: decision.retryAfterMs };
|
|
1609
|
+
},
|
|
1610
|
+
throttleFloorFor(placementKey) {
|
|
1611
|
+
return resolved.minRequestIntervalByKey.get(placementKey) ?? 0;
|
|
1612
|
+
},
|
|
1613
|
+
emit(event) {
|
|
1614
|
+
events.emit(event);
|
|
1615
|
+
},
|
|
1616
|
+
setPlacementState(key, state) {
|
|
1617
|
+
const runtime = placements.get(key);
|
|
1618
|
+
if (runtime) {
|
|
1619
|
+
setPlacement(key, { ...runtime.snapshot, state });
|
|
1620
|
+
}
|
|
1621
|
+
},
|
|
1622
|
+
isReady() {
|
|
1623
|
+
return permitsRequests(enablement) && engine.isAvailable() && canRequest(init.status);
|
|
1624
|
+
},
|
|
1625
|
+
nextCorrelationId() {
|
|
1626
|
+
return correlation.next();
|
|
1627
|
+
},
|
|
1628
|
+
timerCount() {
|
|
1629
|
+
return timers.size();
|
|
1630
|
+
},
|
|
1631
|
+
},
|
|
1632
|
+
|
|
1633
|
+
dispose() {
|
|
1634
|
+
// FR-005: idempotent. A second call is a no-op, not an error.
|
|
1635
|
+
if (disposed) {
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
disposed = true;
|
|
1639
|
+
cancelAllRetries();
|
|
1640
|
+
disarmUndecidedWarning();
|
|
1641
|
+
for (const key of placements.keys()) {
|
|
1642
|
+
releasePlacementResources(key);
|
|
1643
|
+
placements.get(key)?.listeners.clear();
|
|
1644
|
+
}
|
|
1645
|
+
timers.clearAll();
|
|
1646
|
+
requestThrottle.clear();
|
|
1647
|
+
lock.reset();
|
|
1648
|
+
adReturn.reset();
|
|
1649
|
+
statusListeners.clear();
|
|
1650
|
+
consentListeners.clear();
|
|
1651
|
+
events.dispose();
|
|
1652
|
+
inFlightInit = null;
|
|
1653
|
+
},
|
|
1654
|
+
};
|
|
1655
|
+
|
|
1656
|
+
return instance;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* Resolved lazily so importing the package on a platform with no adapter — or
|
|
1661
|
+
* with the bridge absent — costs nothing and crashes nothing (FR-025, SC-004).
|
|
1662
|
+
*/
|
|
1663
|
+
function createDefaultEngine(): AdsEngine {
|
|
1664
|
+
// Deferred require keeps the provider off the import graph of every module
|
|
1665
|
+
// that merely mentions `createAds`.
|
|
1666
|
+
const { createAdMobEngine } = require('../engine/admobEngine') as typeof import('../engine/admobEngine');
|
|
1667
|
+
return createAdMobEngine();
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
declare const __DEV__: boolean | undefined;
|