@chipmobilesdk/rn-ads 0.2.1 → 0.4.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 +94 -1
- package/README.md +879 -671
- package/package.json +49 -49
- package/skills/sdk-rn-ads/SKILL.md +93 -10
- package/src/config/types.ts +40 -0
- package/src/config/validate.ts +211 -188
- package/src/core/adsInstance.ts +1890 -1554
- package/src/core/consentFlow.ts +14 -1
- package/src/core/initMachine.ts +13 -0
- package/src/core/requestThrottle.ts +176 -0
- package/src/diagnostics/events.ts +13 -0
- package/src/engine/admobEngine.ts +26 -0
- package/src/engine/port.ts +9 -0
- package/src/index.ts +103 -91
- package/src/react/AdsBanner.tsx +337 -292
- package/src/testing/index.ts +101 -2
package/README.md
CHANGED
|
@@ -1,671 +1,879 @@
|
|
|
1
|
-
# @chipmobilesdk/rn-ads
|
|
2
|
-
|
|
3
|
-
Reusable advertising capability for ChipMobileSdk-family React Native apps.
|
|
4
|
-
|
|
5
|
-
The package owns **capability**: a safe, consistent, provider-neutral way to request and present ads. Your app owns **policy**: where ads appear, when, how often, to whom, under what consent, and whether they appear at all. There is no placement rule, no frequency cap, and no screen name in this package.
|
|
6
|
-
|
|
7
|
-
**You never import the Google Mobile Ads SDK.** You name a placement key you chose, and the package does the rest.
|
|
8
|
-
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
## Scope
|
|
12
|
-
|
|
13
|
-
| | 0.
|
|
14
|
-
|---|---|
|
|
15
|
-
| **Platform** | Android only. iOS returns `unavailable` / `unsupported_platform` and never breaks a build. |
|
|
16
|
-
| **Provider** | Google AdMob, via `react-native-google-mobile-ads` |
|
|
17
|
-
| **Formats** | Anchored adaptive banner, interstitial, rewarded, app-open |
|
|
18
|
-
| **Consent** | Optional orchestration of the provider's consent flow — you still import nothing of theirs |
|
|
19
|
-
| **
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
| React
|
|
27
|
-
|
|
|
28
|
-
|
|
|
29
|
-
|
|
|
30
|
-
|
|
|
31
|
-
| Google
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
| `android.permission.
|
|
70
|
-
| `android.permission.
|
|
71
|
-
| `
|
|
72
|
-
| `android.permission.
|
|
73
|
-
| `android.permission.
|
|
74
|
-
| `android.permission.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
<uses-permission android:name="android.permission.
|
|
94
|
-
<uses-permission android:name="android.permission.
|
|
95
|
-
<uses-permission android:name="android.permission.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
{ key: '
|
|
115
|
-
{ key: '
|
|
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
|
-
await ads.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
```ts
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
Three
|
|
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
|
-
|
|
1
|
+
# @chipmobilesdk/rn-ads
|
|
2
|
+
|
|
3
|
+
Reusable advertising capability for ChipMobileSdk-family React Native apps.
|
|
4
|
+
|
|
5
|
+
The package owns **capability**: a safe, consistent, provider-neutral way to request and present ads. Your app owns **policy**: where ads appear, when, how often, to whom, under what consent, and whether they appear at all. There is no placement rule, no frequency cap, and no screen name in this package.
|
|
6
|
+
|
|
7
|
+
**You never import the Google Mobile Ads SDK.** You name a placement key you chose, and the package does the rest.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Scope
|
|
12
|
+
|
|
13
|
+
| | 0.4.0 |
|
|
14
|
+
|---|---|
|
|
15
|
+
| **Platform** | Android only. iOS returns `unavailable` / `unsupported_platform` and never breaks a build. |
|
|
16
|
+
| **Provider** | Google AdMob, via `react-native-google-mobile-ads` |
|
|
17
|
+
| **Formats** | Anchored adaptive banner, interstitial, rewarded, app-open |
|
|
18
|
+
| **Consent** | Optional orchestration of the provider's consent flow, including the form a user changes their answer through — you still import nothing of theirs |
|
|
19
|
+
| **Account safety** | Non-production builds cannot reach a production ad unit; each placement has a request floor — see [Request throttling](#request-throttling) |
|
|
20
|
+
| **Deferred** | Native, rewarded-interstitial, mediation, iOS — see [Known limitations](#known-limitations) |
|
|
21
|
+
|
|
22
|
+
## Compatibility
|
|
23
|
+
|
|
24
|
+
| | Supported |
|
|
25
|
+
|---|---|
|
|
26
|
+
| React | `>=19` (verified against 19.2.3) |
|
|
27
|
+
| React Native | `>=0.85`, New Architecture (verified against 0.85.3) |
|
|
28
|
+
| Node | `>=22.11.0` |
|
|
29
|
+
| Android | `minSdk 24`, `compileSdk 36`, `targetSdk 36`, Kotlin 2.1.x |
|
|
30
|
+
| Provider bridge | `react-native-google-mobile-ads` **`>=16.0.3 <16.1.0`** |
|
|
31
|
+
| Google Mobile Ads Android SDK | 24.9.0 (transitive) |
|
|
32
|
+
| Google UMP (consent) | 4.0.0 (transitive) |
|
|
33
|
+
|
|
34
|
+
> **`0.2.0` required no bridge bump, and neither did `0.4.0`.** The pinned `16.0.3` already exposes everything the consent flow, the app-open format and the privacy options form need (`AdsConsent.showPrivacyOptionsForm()` and `privacyOptionsRequirementStatus` on the consent info) — verified against the installed type declarations and the bridge's Android module rather than assumed from documentation. The Kotlin constraint below is therefore untouched by both releases, and the merged manifest gains no permission.
|
|
35
|
+
|
|
36
|
+
> **Why the bridge range is closed at the top.** Bridge 16.1.0+ moves to Google Mobile Ads 25.x, which is compiled with Kotlin 2.3.0 metadata. React Native 0.85.3 pins the Kotlin **compiler** at 2.1.x, and the build fails with `Module was compiled with an incompatible version of Kotlin`. Raising the root `ext.kotlinVersion` does not fix it — that moves only `kotlin-stdlib`, not the compiler. An open caret would let a routine `npm install` walk straight back into a build failure, so the range refuses rather than drifts. Revisit when React Native ships a Kotlin 2.3 compiler.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npm install @chipmobilesdk/rn-ads react-native-google-mobile-ads@16.0.3
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Required Android configuration
|
|
47
|
+
|
|
48
|
+
Add your AdMob **application ID** to the app's `app.json`. The bridge's Gradle script reads this key and merges it into `AndroidManifest.xml`; without it the native SDK crashes on startup.
|
|
49
|
+
|
|
50
|
+
```jsonc
|
|
51
|
+
{
|
|
52
|
+
"react-native-google-mobile-ads": {
|
|
53
|
+
// Google's public test application ID. Replace with your own for release.
|
|
54
|
+
"android_app_id": "ca-app-pub-3940256099942544~3347511713"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**The shared package carries no application ID and no ad unit ID.** Both are per-app configuration.
|
|
60
|
+
|
|
61
|
+
### Permissions merged into your manifest
|
|
62
|
+
|
|
63
|
+
Contributed by the provider, not requested by this package:
|
|
64
|
+
|
|
65
|
+
Verified against a real merged manifest, not a list from documentation:
|
|
66
|
+
|
|
67
|
+
| Permission | Source |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `android.permission.INTERNET` | bridge manifest |
|
|
70
|
+
| `android.permission.ACCESS_NETWORK_STATE` | bridge manifest |
|
|
71
|
+
| `android.permission.WAKE_LOCK` | bridge manifest |
|
|
72
|
+
| `com.google.android.gms.permission.AD_ID` | `play-services-ads-api` |
|
|
73
|
+
| `android.permission.ACCESS_ADSERVICES_AD_ID` | `play-services-ads-api` |
|
|
74
|
+
| `android.permission.ACCESS_ADSERVICES_ATTRIBUTION` | `play-services-ads-api` |
|
|
75
|
+
| `android.permission.ACCESS_ADSERVICES_TOPICS` | `play-services-ads-api` |
|
|
76
|
+
|
|
77
|
+
**Re-verified for `0.2.0`.** Adding the app-open format and activating the consent module contributed **no new permission** — both live in the already-pinned bridge, so the merged result is byte-identical to `0.1.0`'s.
|
|
78
|
+
|
|
79
|
+
The last three are **Android Privacy Sandbox** permissions covering the Privacy Sandbox ad ID, attribution reporting, and the Topics API. They matter for your Data safety declaration and they are easy to miss, because they appear only in the merged result — no library README lists them.
|
|
80
|
+
|
|
81
|
+
Verify the actual merged result before release — read the file, don't trust this table:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
cd android && ./gradlew :app:assembleDebug
|
|
85
|
+
# then inspect android/app/build/intermediates/merged_manifests/**/AndroidManifest.xml
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**To remove advertising identifier access** (required for child-directed apps under Play Families policy).
|
|
89
|
+
|
|
90
|
+
Removing `AD_ID` alone is **not enough** — verified by rebuild, the three Privacy Sandbox permissions survive it and must be removed explicitly:
|
|
91
|
+
|
|
92
|
+
```xml
|
|
93
|
+
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />
|
|
94
|
+
<uses-permission android:name="android.permission.ACCESS_ADSERVICES_AD_ID" tools:node="remove" />
|
|
95
|
+
<uses-permission android:name="android.permission.ACCESS_ADSERVICES_ATTRIBUTION" tools:node="remove" />
|
|
96
|
+
<uses-permission android:name="android.permission.ACCESS_ADSERVICES_TOPICS" tools:node="remove" />
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Your `<manifest>` tag needs `xmlns:tools="http://schemas.android.com/tools"`. Rebuild and re-inspect the merged manifest to confirm — do not assume.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Configure
|
|
104
|
+
|
|
105
|
+
Placement keys are **yours**. Provider ad unit IDs appear here in configuration and nowhere else in your code.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { createAds } from '@chipmobilesdk/rn-ads';
|
|
109
|
+
|
|
110
|
+
const ads = createAds({
|
|
111
|
+
enabled: true, // your feature flag; false makes everything a safe no-op
|
|
112
|
+
environment: 'development', // 'development' | 'test' | 'production'
|
|
113
|
+
placements: [
|
|
114
|
+
{ key: 'home_footer', format: 'banner', androidAdUnitId: 'ca-app-pub-…/…' },
|
|
115
|
+
{ key: 'level_break', format: 'interstitial', androidAdUnitId: 'ca-app-pub-…/…' },
|
|
116
|
+
{ key: 'completion_reward', format: 'rewarded', androidAdUnitId: 'ca-app-pub-…/…' },
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`createAds` **throws** on a malformed catalog — duplicate key, missing ad unit, unknown format, out-of-band timeout — naming the offending placement. Every other failure in the package is a typed result, never a throw.
|
|
122
|
+
|
|
123
|
+
Reading configuration requires no dotenv library, no remote-config service, and no feature-flag system. Supply the values however you like.
|
|
124
|
+
|
|
125
|
+
### How production traffic is made impossible
|
|
126
|
+
|
|
127
|
+
In `development` and `test`, any ad unit that is not one of Google's official test units is **replaced** with one, and the substitution is reported — as a `config_warning` event naming every affected placement, and in observable state via `ads.getSubstitutedPlacements()`.
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
environment: development
|
|
131
|
+
home_footer -> ca-app-pub-REAL…/1234567890
|
|
132
|
+
|
|
133
|
+
⚠ config_warning: test_ad_unit_substituted
|
|
134
|
+
placementKeys: ['home_footer']
|
|
135
|
+
→ request sent with ca-app-pub-3940256099942544/9214589741
|
|
136
|
+
→ your app keeps working
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
In `production`, nothing is substituted — your units are used exactly as configured.
|
|
140
|
+
|
|
141
|
+
The one case substitution cannot cover is a **debug build declaring `production`**: that would send real requests from a build that should never generate traffic. It fails closed with `production_in_dev_build`.
|
|
142
|
+
|
|
143
|
+
> Invalid traffic from non-production builds can get an AdMob account suspended. **Never click a production ad to verify anything.**
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Initialize — consent comes first
|
|
148
|
+
|
|
149
|
+
The package will not initialize anything request-capable until your privacy gate permits it. This is enforced, not documented: with the gate withheld, **zero** provider calls occur.
|
|
150
|
+
|
|
151
|
+
```tsx
|
|
152
|
+
import { AdsProvider } from '@chipmobilesdk/rn-ads';
|
|
153
|
+
|
|
154
|
+
await ads.initialize({
|
|
155
|
+
canRequestAds: true, // your consent decision — the package renders no consent UI
|
|
156
|
+
consentUndetermined: false, // true means "not asked yet", distinct from "refused"
|
|
157
|
+
childDirected: false,
|
|
158
|
+
underAgeOfConsent: false,
|
|
159
|
+
maxAdContentRating: 'G',
|
|
160
|
+
personalizedAds: true,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
<AdsProvider instance={ads}>{children}</AdsProvider>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**Ordering matters.** `childDirected`, `underAgeOfConsent`, and `maxAdContentRating` only take effect if they reach the provider *before* it initializes. Change one afterwards and `updatePrivacy` returns `requires_reinitialization` rather than pretending it applied.
|
|
167
|
+
|
|
168
|
+
Resolving consent after being blocked is the normal path and creates **no** duplicate initialization:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
await ads.initialize({ canRequestAds: false }); // → { status: 'blocked', reason: 'consent_required' }
|
|
172
|
+
await ads.updatePrivacy({ canRequestAds: true }); // → { status: 'applied' }, one native init total
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**If you use the package's consent flow, it is part of the gate too.** Once you have called `ads.consent.*`, `initialize()` waits for the flow to permit requests, exactly as `load` does:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
await ads.consent.requestInfoUpdate(); // the user declines, or the flow fails
|
|
179
|
+
await ads.initialize({ canRequestAds: true }); // → { status: 'blocked', reason: 'consent_flow_required' }
|
|
180
|
+
// no native initialization
|
|
181
|
+
// ...later the user consents, through either consent form:
|
|
182
|
+
// the package initializes the provider itself — once — and the status becomes `ready`.
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Before `0.4.0` this returned `ready` and initialized the provider even though `load` refused, which goes against the provider's own guidance not to initialize before consent permits requests. Calling `initialize()` *before* any `ads.consent.*` call still initializes on your own gate alone, as in `0.1.0`.
|
|
186
|
+
|
|
187
|
+
The package logs no personal data, no consent payload, and no advertising identifier.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Banner
|
|
192
|
+
|
|
193
|
+
You place it. It sizes itself to your container and re-adapts on rotation.
|
|
194
|
+
|
|
195
|
+
```tsx
|
|
196
|
+
import { AdsBanner, usePlacementState } from '@chipmobilesdk/rn-ads';
|
|
197
|
+
|
|
198
|
+
function Footer() {
|
|
199
|
+
const state = usePlacementState('home_footer');
|
|
200
|
+
const reserve = state === 'loading' || state === 'ready';
|
|
201
|
+
|
|
202
|
+
return reserve ? (
|
|
203
|
+
<View style={styles.slot}>
|
|
204
|
+
{state === 'loading' ? <YourThemedPlaceholder /> : null}
|
|
205
|
+
<AdsBanner placementKey="home_footer" />
|
|
206
|
+
</View>
|
|
207
|
+
) : null; // your decision to reclaim the space
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The component **ships no placeholder** — that is yours, so it matches your theme. When ads are disabled or unavailable it renders `null`, not an empty view, so it leaves the accessibility tree entirely rather than taking screen-reader focus ahead of your content.
|
|
212
|
+
|
|
213
|
+
## Interstitial and rewarded
|
|
214
|
+
|
|
215
|
+
Load ahead of time; show only when *you* decide.
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
await ads.load('level_break');
|
|
219
|
+
const result = await ads.show('level_break');
|
|
220
|
+
|
|
221
|
+
switch (result.status) {
|
|
222
|
+
case 'shown': break;
|
|
223
|
+
case 'not_ready': break; // no UI opened, nothing crashed
|
|
224
|
+
case 'busy': break; // another full-screen ad is up — never queued
|
|
225
|
+
case 'consumed': break; // load a fresh one first
|
|
226
|
+
case 'app_inactive': break; // backgrounded
|
|
227
|
+
case 'blocked': break; // result.reason
|
|
228
|
+
case 'disabled': break;
|
|
229
|
+
case 'unavailable': break; // result.reason
|
|
230
|
+
case 'timeout': break;
|
|
231
|
+
case 'failed': break; // result.error
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Rewards are reported **only** when the provider confirms them:
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
ads.subscribeEvents(event => {
|
|
239
|
+
if (event.type === 'reward_earned') {
|
|
240
|
+
grant(event.reward.type, event.reward.amount); // your grant, not the package's
|
|
241
|
+
}
|
|
242
|
+
if (event.type === 'dismissed' && event.rewardEarned === false) {
|
|
243
|
+
// closed early — no reward, and none inferred
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`reward_earned` and `dismissed` may arrive from the provider in either order; the outcome is identical, and a duplicate confirmation is collapsed into one event. **The package grants nothing** — validating and delivering the benefit is yours.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## State, events, and errors
|
|
253
|
+
|
|
254
|
+
**Initialization status**: `not_started` · `blocked` · `initializing` · `ready` · `disabled` · `unavailable` · `failed`
|
|
255
|
+
|
|
256
|
+
**Placement state**: `idle` · `loading` · `ready` · `showing` · `consumed` · `failed` · `unavailable`
|
|
257
|
+
|
|
258
|
+
**Events** carry the placement key, format, timestamp, and a `correlationId` tying one load/show cycle together — enough to reconstruct an ad's whole life from the stream alone. They carry no provider object, no advertising identifier, and no consent payload.
|
|
259
|
+
|
|
260
|
+
`initialization` · `load_started` · `loaded` · `load_failed` · `display_started` · `impression` · `click` · `display_failed` · `dismissed` · `reward_earned` · `disabled` · `unavailable` · `config_warning` · `internal_error`
|
|
261
|
+
|
|
262
|
+
> **One honest asymmetry.** `impression` carries `source: 'provider' | 'derived'`. Banners get a real provider impression callback. Full-screen formats have none in the provider's event enum, so theirs is derived from the ad opening. If you reconcile these events against AdMob revenue reporting, you need to know which is which.
|
|
263
|
+
|
|
264
|
+
**Ordering and exactly-once** are documented and tested. Within one `correlationId`, `loaded` / `display_started` / `impression` / `dismissed` / `reward_earned` occur at most once; `click` may repeat. After `dispose()`, zero events are emitted — a late provider callback is dropped.
|
|
265
|
+
|
|
266
|
+
**Errors** carry a stable `code`, a log-safe `message`, the `operation`, a `retryable` flag, and a redacted `cause`. Codes are public API; renaming one is a breaking change.
|
|
267
|
+
|
|
268
|
+
Retryable: `no_fill` `network_error` `load_timeout` `show_timeout` `ad_expired` `not_ready` `busy` `consumed` `app_inactive` `provider_error`
|
|
269
|
+
Not retryable: `invalid_config` `duplicate_placement_key` `missing_ad_unit_id` `format_mismatch` `placement_not_found` `production_in_dev_build` `timeout_out_of_bounds` `unsupported_platform` `provider_missing` `disabled` `consent_required` `consent_undetermined` `requires_reinitialization` `internal_error`
|
|
270
|
+
|
|
271
|
+
Attach any logger or analytics you already use. The package depends on none:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
const unsubscribe = ads.subscribeEvents(event => yourLogger.track(event.type, event));
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
A listener that throws is caught and reported once; it cannot break the state machine or starve other listeners.
|
|
278
|
+
|
|
279
|
+
### Timeouts and cleanup
|
|
280
|
+
|
|
281
|
+
| | Default | Configurable range |
|
|
282
|
+
|---|---|---|
|
|
283
|
+
| Load timeout | 30 000 ms | 5 000 – 120 000 |
|
|
284
|
+
| Show timeout | 10 000 ms | 5 000 – 30 000 |
|
|
285
|
+
| Loaded-ad validity | 3 600 000 ms | 60 000 – 3 600 000 |
|
|
286
|
+
| App-open validity | 1 800 000 ms | 60 000 – 3 600 000 |
|
|
287
|
+
| Undecided warning | 10 000 ms | 1 000 – 120 000 |
|
|
288
|
+
|
|
289
|
+
Out-of-band values are a configuration error, not silently clamped. A loaded full-screen ad older than its validity window stops reporting `ready` and requires a reload — so you never render a button for an ad that will fail on tap.
|
|
290
|
+
|
|
291
|
+
`dispose()` cancels every timer and subscription and releases every handle — including a consent flow in progress, a pending retry, and the undecided-state warning. It is idempotent, and so is every `Unsubscribe`.
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Retry
|
|
296
|
+
|
|
297
|
+
**Off by default.** Upgrading from `0.1.0` without configuring `retry` sends exactly the requests it always did.
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
createAds({
|
|
301
|
+
// ...
|
|
302
|
+
retry: { maxRetries: 2, initialDelayMs: 2_000, maxDelayMs: 30_000 },
|
|
303
|
+
placements: [
|
|
304
|
+
{ key: 'home_footer', format: 'banner', androidAdUnitId: '...' },
|
|
305
|
+
// Off for this placement only: a rewarded retry happens with a user
|
|
306
|
+
// waiting on the other side of it.
|
|
307
|
+
{ key: 'bonus', format: 'rewarded', androidAdUnitId: '...', retry: false },
|
|
308
|
+
],
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
| | Default | Range |
|
|
313
|
+
|---|---|---|
|
|
314
|
+
| `maxRetries` (attempts after the first) | 2 | 1 – 5 |
|
|
315
|
+
| `initialDelayMs` | 2 000 | 500 – 30 000 |
|
|
316
|
+
| `maxDelayMs` | 30 000 | 1 000 – 60 000 |
|
|
317
|
+
|
|
318
|
+
Backoff is exponential with a fixed x2 multiplier and no jitter — a single device has no fleet to desynchronize, and determinism makes the attempt count exactly assertable.
|
|
319
|
+
|
|
320
|
+
**Only `retryable` errors are retried.** Configuration errors, consent errors, an unsupported platform, and a disabled or undecided instance never are: another attempt cannot change any of them, so retrying would spend a billable request on a certainty.
|
|
321
|
+
|
|
322
|
+
A per-placement `retry` **replaces** the instance-wide policy rather than merging into it, so the effective policy for a placement is readable from the configuration without simulating a merge. Both are held to the same bounds, so an override can narrow behavior but never exceed a ceiling. Out-of-range values throw at construction — you find out when you build the instance, not on the first failed load in production.
|
|
323
|
+
|
|
324
|
+
Disabling ads, moving to `undecided`, losing consent, or `dispose()` cancels **every** pending retry across every placement immediately.
|
|
325
|
+
|
|
326
|
+
### Reconciling retries against a bill
|
|
327
|
+
|
|
328
|
+
Each attempt emits a `retry_scheduled` event carrying the **same `correlationId`** as the load cycle that triggered it:
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
ads.subscribeEvents(event => {
|
|
332
|
+
if (event.type === 'retry_scheduled') {
|
|
333
|
+
analytics.track('ad_retry', {
|
|
334
|
+
placement: event.placementKey,
|
|
335
|
+
attempt: event.attempt,
|
|
336
|
+
delayMs: event.delayMs,
|
|
337
|
+
reason: event.error.code,
|
|
338
|
+
cycle: event.correlationId,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Counting `load_started` plus `retry_scheduled` for a placement gives the number of requests that placement actually cost you. You keep the option of ignoring all of this and writing your own retry — enabling the package's is a choice, not a replacement.
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## Request throttling
|
|
349
|
+
|
|
350
|
+
**New in 0.3.0.** Each placement has a minimum interval between provider *requests*.
|
|
351
|
+
|
|
352
|
+
### The problem it solves
|
|
353
|
+
|
|
354
|
+
A banner mounted inside a view the user navigates between — a day in a calendar, a tab, a row that scrolls in and out of a list — is unmounted and remounted by React on every navigation. **Each remount is a billable request.** A user swiping quickly through ten days produces ten requests in a few seconds, and nothing about that is you doing something wrong.
|
|
355
|
+
|
|
356
|
+
Ad networks read that pattern as invalid traffic. The penalty is account-level and shipping a fix does not recover it, which is why this is enforced by the package rather than left as advice.
|
|
357
|
+
|
|
358
|
+
Full-screen formats do not have the problem: they are loaded by an explicit call, not mounted by a renderer. That asymmetry is why the defaults differ.
|
|
359
|
+
|
|
360
|
+
### Defaults
|
|
361
|
+
|
|
362
|
+
| Format | Default floor | Why |
|
|
363
|
+
|---|---|---|
|
|
364
|
+
| `banner` | **60 000 ms** | AdMob's own documented floor for banner auto-refresh. Asking more often than that is, by the provider's own standard, more often than it considers reasonable. |
|
|
365
|
+
| `interstitial`, `rewarded`, `app_open` | **0** (off) | `load` → `show` → `load` is the healthy cycle. An app that legitimately sells three things in two minutes loads three times, and a default floor would break correct apps to solve a problem these formats do not have. |
|
|
366
|
+
|
|
367
|
+
Override per placement:
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
placements: [
|
|
371
|
+
{
|
|
372
|
+
key: 'timeline_row',
|
|
373
|
+
format: 'banner',
|
|
374
|
+
androidAdUnitId: 'ca-app-pub-…/…',
|
|
375
|
+
minRequestIntervalMs: 30_000, // lower the floor knowingly
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
key: 'after_save',
|
|
379
|
+
format: 'interstitial',
|
|
380
|
+
androidAdUnitId: 'ca-app-pub-…/…',
|
|
381
|
+
minRequestIntervalMs: 20_000, // add a floor where there is none by default
|
|
382
|
+
},
|
|
383
|
+
]
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Bounds are `0`–`600 000` ms, and an out-of-range value throws `AdsConfigError` at `createAds` — naming the placement — rather than refusing every request later in production.
|
|
387
|
+
|
|
388
|
+
### What a refusal looks like
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
const result = await ads.load('after_save');
|
|
392
|
+
if (result.status === 'throttled') {
|
|
393
|
+
// Nothing was requested. Nothing is wrong.
|
|
394
|
+
console.log(`asked too recently; retry in ${result.retryAfterMs}ms`);
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
- The placement reports `'throttled'` — **not** `'failed'`, and **not** a no-fill. No-fill means the network had nothing to give; this means we did not ask. An app that conflates them will misdiagnose the first time a placement goes quiet.
|
|
399
|
+
- A `request_throttled` event carries `retryAfterMs` and the `minRequestIntervalMs` that refused it, so the cause is readable from your logs alone.
|
|
400
|
+
- No `load_started` is emitted. A refused request is not a cycle, and the event stream must not claim a billable request that never happened.
|
|
401
|
+
|
|
402
|
+
### Two exemptions, both deliberate
|
|
403
|
+
|
|
404
|
+
**Rotation is not charged.** A width re-adaptation inside one mount — the device rotated, the container resized — is the same banner the placement already legitimately requested. Refusing it would leave a rotated screen with no banner for up to a minute, which this was never meant to cost. The slot is charged once per *mount*, not per request.
|
|
405
|
+
|
|
406
|
+
**A concurrent duplicate load is not charged.** Two callers arriving for one cycle already join the in-flight request; charging the second would turn an existing guarantee into a refusal.
|
|
407
|
+
|
|
408
|
+
### The better fix is architectural
|
|
409
|
+
|
|
410
|
+
> Keep the banner **outside** the subtree that remounts and the throttle never fires. A banner pinned above or below a swipeable area stays mounted across navigation, keeps its ad, and costs one request.
|
|
411
|
+
>
|
|
412
|
+
> This throttle is a safety net for when that is not possible — a banner genuinely inside a list, for instance. It is not a licence to ignore the placement problem, and reaching for `minRequestIntervalMs: 0` to silence a `throttled` state puts your ad account back where it was.
|
|
413
|
+
|
|
414
|
+
### This is not a frequency cap
|
|
415
|
+
|
|
416
|
+
It limits how often a placement may **ask** for an ad. It says nothing about how often you may **show** one, and the package deliberately ships no pacing mechanism at all.
|
|
417
|
+
|
|
418
|
+
Pacing is a product decision with no correct default: one app counts items created, another counts screens opened, another counts minutes elapsed. Shipping one here would make the package own policy that belongs to you. If you want "an interstitial every third save", count the saves in your app and call `show` when your rule says so.
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## Turning ads off — three states
|
|
423
|
+
|
|
424
|
+
`enabled` accepts `true`, `false`, or `'undecided'`.
|
|
425
|
+
|
|
426
|
+
| State | Requests sent | Init status | Banner reports | When to use it |
|
|
427
|
+
|---|---|---|---|---|
|
|
428
|
+
| `true` | yes | normal | normal | Ads are on |
|
|
429
|
+
| `false` | **zero** | `disabled` | `unavailable` | A settled no |
|
|
430
|
+
| `'undecided'` | **zero** | `undecided` | `undecided` | You do not know yet |
|
|
431
|
+
|
|
432
|
+
### Why the third state exists
|
|
433
|
+
|
|
434
|
+
If you sell an ad-removal tier, at cold start you do not yet know whether this user has paid — that answer arrives after a store sync. Both of the other options are wrong: `true` flashes an ad at someone who paid not to see one, and `false` delays ads for every free user and drops you into the re-enable path below.
|
|
435
|
+
|
|
436
|
+
`undecided` sends nothing, exactly like `disabled`, but reports itself **distinctly** so your banner host can hold the space rather than reclaim it:
|
|
437
|
+
|
|
438
|
+
```tsx
|
|
439
|
+
<AdsBanner placementKey="home_footer" onStateChange={setState} />
|
|
440
|
+
{state === 'undecided' ? <YourThemedPlaceholder /> : null}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Resolve it exactly once when you know:
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
const result = ads.setEnablement(userOwnsAdRemoval ? 'disabled' : 'enabled');
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
The package **never resolves it for you** — that would mean guessing at a business decision it is forbidden to know about. If you leave it unresolved past `undecidedWarningMs` you get one `config_warning` (`enablement_undecided_timeout`) and nothing else. One, not a repeating stream: a warning that fires every ten seconds becomes noise that gets filtered.
|
|
450
|
+
|
|
451
|
+
### Re-enabling
|
|
452
|
+
|
|
453
|
+
**This changed in `0.2.0`, and the old behavior was a defect.** In `0.1.0`, `setEnabled(true)` after a disable dropped the init status to `not_started`, so every later `load` and `show` was refused — and `setEnabled` returned `void`, so nothing told you. An app whose user's subscription lapsed would turn ads back on and silently show none.
|
|
454
|
+
|
|
455
|
+
Now the result is typed and always says what happened:
|
|
456
|
+
|
|
457
|
+
```ts
|
|
458
|
+
const result = ads.setEnabled(true);
|
|
459
|
+
|
|
460
|
+
switch (result.status) {
|
|
461
|
+
case 'resumed':
|
|
462
|
+
// The provider was never un-initialized. Nothing to do — load away.
|
|
463
|
+
break;
|
|
464
|
+
case 'requires_initialization':
|
|
465
|
+
// Never reached ready, privacy changed, or the platform went away.
|
|
466
|
+
await ads.initialize(privacy);
|
|
467
|
+
break;
|
|
468
|
+
default:
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
The package does **not** initialize implicitly on your behalf: that would hide an asynchronous, billable operation behind a setter you may not be awaiting, and a failure would surface with no call site to attribute it to.
|
|
474
|
+
|
|
475
|
+
If your privacy options have not changed you do not supply them again. They live in memory for the lifetime of the instance and are never persisted.
|
|
476
|
+
|
|
477
|
+
In every off state — `false` or `'undecided'` — in-flight loads are released deterministically, and an ad **already on screen is not force-closed**: the provider offers no safe way to do so, and yanking a full-screen ad out from under the user would be worse than letting it finish.
|
|
478
|
+
|
|
479
|
+
### Wiring it to an external decision
|
|
480
|
+
|
|
481
|
+
The package has no idea what an entitlement is. That is deliberate — it receives a decision, never a reason:
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
const ads = createAds({ enabled: 'undecided', /* ... */ });
|
|
485
|
+
|
|
486
|
+
// Whatever answers this for you: a billing library, your own backend, a cached
|
|
487
|
+
// receipt. The package neither knows nor stores which.
|
|
488
|
+
entitlements.subscribe(owned => {
|
|
489
|
+
ads.setEnablement(owned ? 'disabled' : 'enabled');
|
|
490
|
+
});
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
## Consent
|
|
496
|
+
|
|
497
|
+
**Optional.** If you already supply your own gate through `PrivacyOptions.canRequestAds`, nothing here starts on your behalf and your `0.1.0` integration is untouched.
|
|
498
|
+
|
|
499
|
+
Use it when you ship to a region that requires consent. Before `0.2.0` that meant importing the provider's consent SDK directly — which broke the one promise this package exists to keep — and hand-writing the same order-sensitive orchestration every other such app was also writing.
|
|
500
|
+
|
|
501
|
+
```ts
|
|
502
|
+
// Nothing here imports the ad provider.
|
|
503
|
+
const info = await ads.consent.requestInfoUpdate();
|
|
504
|
+
|
|
505
|
+
if (info.state === 'required') {
|
|
506
|
+
await ads.consent.showFormIfRequired(); // the provider draws the form
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// The outcome feeds the privacy gate by itself. No wiring, and no second
|
|
510
|
+
// native initialization.
|
|
511
|
+
await ads.load('level_break');
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
### Normalized consent states
|
|
515
|
+
|
|
516
|
+
| State | Meaning | `canRequestAds` | Your next step |
|
|
517
|
+
|---|---|---|---|
|
|
518
|
+
| `undetermined` | Not asked yet. Initial value, and the value after a reset. | `false` | `requestInfoUpdate()` |
|
|
519
|
+
| `not_required` | The provider says this region does not require consent. | `true` | Nothing — ads proceed |
|
|
520
|
+
| `required` | Required and not yet obtained. | `false` | `showFormIfRequired()` |
|
|
521
|
+
| `obtained` | Obtained. | `true` | Nothing — ads proceed |
|
|
522
|
+
| `error` | The flow failed. | **always `false`** | Optionally retry the flow |
|
|
523
|
+
|
|
524
|
+
Three rules the package will not bend:
|
|
525
|
+
|
|
526
|
+
- **A failure is never consent.** A provider error, a network failure, a cancellation, and a form dismissed without an answer all produce `error`, and `error` never carries permission. The failure this prevents is invisible in production: a request that should not have happened looks exactly like one that should.
|
|
527
|
+
- **The package does not decide which regions require consent.** `not_required` is the provider's answer forwarded, not this package's judgement.
|
|
528
|
+
- **A consent failure never crashes your app or blocks startup.** It is a normalized error you can act on or ignore.
|
|
529
|
+
|
|
530
|
+
The package renders **no consent UI**. The form is the provider's. Timing, surrounding copy, and where you offer a user the chance to change their mind stay yours — as does deciding whether to run the flow at all. A user declining is a normal path, not an error.
|
|
531
|
+
|
|
532
|
+
### Letting users change their choice
|
|
533
|
+
|
|
534
|
+
**New in `0.4.0`.** GDPR requires that withdrawing consent be as easy as giving it. The provider models that as a separate capability — a *privacy options* form, and a status saying whether you must offer it — and the package now exposes both. Before `0.4.0` the only route was importing the provider, or calling `consent.reset()` and re-showing the form: a testing-only call that wipes the stored decision while the form is open.
|
|
535
|
+
|
|
536
|
+
You need two things from the package: **whether to show the entry**, and **a call that shows the form**.
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
// After requestInfoUpdate(), read the provider's verdict.
|
|
540
|
+
const { privacyOptionsRequirement } = ads.consent.getState();
|
|
541
|
+
const showEntry = privacyOptionsRequirement === 'required';
|
|
542
|
+
|
|
543
|
+
// In your Settings screen, behind your own row, label and copy:
|
|
544
|
+
const result = await ads.consent.showPrivacyOptionsForm();
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
`privacyOptionsRequirement` lives on the consent snapshot, so `consent.subscribe` already tells you when it changes.
|
|
548
|
+
|
|
549
|
+
| `privacyOptionsRequirement` | Meaning | Your entry |
|
|
550
|
+
|---|---|---|
|
|
551
|
+
| `required` | The provider says this user must be able to change their choice. | Show it |
|
|
552
|
+
| `not_required` | The provider says no entry is needed here. | Hide it |
|
|
553
|
+
| `unknown` | No successful `requestInfoUpdate()` yet, or after `reset()`, after a failed update, or on iOS. | Hide it, but do not treat it as `not_required` |
|
|
554
|
+
|
|
555
|
+
`showPrivacyOptionsForm()` never throws. It returns:
|
|
556
|
+
|
|
557
|
+
| Result | What happened | Presented? |
|
|
558
|
+
|---|---|---|
|
|
559
|
+
| `{ status: 'shown', consent }` | The form opened and closed. `consent` is the state it left behind, already applied to the gate. A user who backed out unchanged lands here too, with the state they had. | Yes |
|
|
560
|
+
| `{ status: 'not_required' }` | The requirement is not `required`. | No |
|
|
561
|
+
| `{ status: 'info_update_required' }` | The requirement is `unknown`; call `requestInfoUpdate()` first. | No |
|
|
562
|
+
| `{ status: 'busy' }` | A consent form is already on screen. | No |
|
|
563
|
+
| `{ status: 'failed', error }` | The form could not open, or failed while open. `error.code` is `consent_flow_failed`. | Maybe |
|
|
564
|
+
| `{ status: 'unavailable', reason }` | iOS (`unsupported_platform`), or after `dispose()` (`provider_missing`). | No |
|
|
565
|
+
|
|
566
|
+
**What the outcome does to the gate.** You wire nothing; it feeds the privacy gate exactly as `showFormIfRequired` does.
|
|
567
|
+
|
|
568
|
+
- **Consent withdrawn** (`canRequestAds` false): the status moves from `ready` to `blocked`, every in-flight load settles with `{ status: 'blocked', reason: 'consent_flow_required' }`, loaded-but-unshown ads are released, pending retries are cancelled, and a mounted `AdsBanner` leaves the tree. No new request is made.
|
|
569
|
+
- **Consent granted again**: requests resume and the status returns to `ready` **without a second native initialization** — one per instance, however many times the user changes their mind. That holds if your app re-runs `initialize()` on a consent change, too.
|
|
570
|
+
- **An ad already on screen is not force-closed.** The provider offers no safe way to close one.
|
|
571
|
+
- **Most-restrictive-wins is unchanged.** If your own gate is closed, a user re-granting consent in the form does not reopen it.
|
|
572
|
+
|
|
573
|
+
Keep in mind that a user who rejects every purpose in the form usually still leaves `canRequestAds` **true**: in the provider's terms, `obtained` means an answer was recorded, and the ads SDK applies that answer — limited or non-personalized ads — to later requests by itself. So the common outcome of the form is "the gate stays open, and later ads honour the new choice". The withdrawal path above is what happens when the provider does report `canRequestAds` false.
|
|
574
|
+
|
|
575
|
+
**A failure is still never consent.** If the form fails, the package asks the provider what it still holds — the form never completed, so the stored decision has not changed — and reports that. If the provider cannot answer either, the state becomes `error` and the gate closes. Neither path grants anything.
|
|
576
|
+
|
|
577
|
+
**What stays yours:** where the entry lives, its label, its copy, and when to offer it — for example, hiding it from a user who bought ad removal. The package never shows the form on its own.
|
|
578
|
+
|
|
579
|
+
### Package flow or your own gate?
|
|
580
|
+
|
|
581
|
+
| | Package flow | Your own gate |
|
|
582
|
+
|---|---|---|
|
|
583
|
+
| You call | `ads.consent.*` | `initialize({ canRequestAds })` |
|
|
584
|
+
| Form drawn by | the provider | you, or a third-party CMP |
|
|
585
|
+
| Good when | you use the provider's consent messages | you already run a consent platform |
|
|
586
|
+
|
|
587
|
+
You can do both. They combine as **most-restrictive-wins**: requesting is permitted only when both permit it, and either alone can block. So if you obtained consent but independently blocked ads for a reason of your own, your block survives — the package is forbidden to know your reason and therefore must never overrule it. The reported reason names which input is blocking.
|
|
588
|
+
|
|
589
|
+
### Testing the flow from outside the region
|
|
590
|
+
|
|
591
|
+
```ts
|
|
592
|
+
createAds({
|
|
593
|
+
environment: 'development',
|
|
594
|
+
consentTesting: { debugGeography: 'eea', resetOnStart: true },
|
|
595
|
+
// ...
|
|
596
|
+
});
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
**Both tools are stripped in `production`.** Not checked at each call site — removed at configuration validation, so no code path can forget. The fact that they were ignored is observable, not merely documented: a `config_warning` (`consent_tool_ignored_in_production`) naming each one, and `getIgnoredConsentTools()` on the instance.
|
|
600
|
+
|
|
601
|
+
### What gets stored on the device
|
|
602
|
+
|
|
603
|
+
The consent platform persists the user's decision on the device under IAB TCF keys. This package neither reads, logs, nor includes it in any event — but **your Google Play Data safety declaration depends on it**, so review that before shipping the consent flow.
|
|
604
|
+
|
|
605
|
+
---
|
|
606
|
+
|
|
607
|
+
## App open
|
|
608
|
+
|
|
609
|
+
A fourth format, declared in the catalog exactly like the other three and using the same placement states, events, and error codes.
|
|
610
|
+
|
|
611
|
+
```ts
|
|
612
|
+
{ key: 'cold_start', format: 'app_open', androidAdUnitId: 'ca-app-pub-.../...' }
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
```ts
|
|
616
|
+
await ads.load('cold_start');
|
|
617
|
+
const result = await ads.show('cold_start'); // you decide when
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
**The package never shows one on its own.** As with interstitial and rewarded, every show is an explicit call you make.
|
|
621
|
+
|
|
622
|
+
It shares the **same single full-screen lock** as interstitial and rewarded, so two full-screen ads are never open at once, and a refused request is never queued.
|
|
623
|
+
|
|
624
|
+
### Ad-originated return
|
|
625
|
+
|
|
626
|
+
This is the reason app-open belongs in the package rather than in your app.
|
|
627
|
+
|
|
628
|
+
When a user dismisses a full-screen ad — or comes back from a browser an ad opened — your app sees an ordinary return to the foreground and has no way to tell it apart from any other. Showing an app-open there means an ad immediately after an ad. The package can tell, because it holds the lock and sees the clicks, so it refuses:
|
|
629
|
+
|
|
630
|
+
```ts
|
|
631
|
+
const result = await ads.show('cold_start');
|
|
632
|
+
if (result.status === 'ad_originated_return') {
|
|
633
|
+
// Distinct from `busy` and `not_ready` on purpose: neither of those would
|
|
634
|
+
// have told you what actually happened, and both suggest a wrong remedy.
|
|
635
|
+
}
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
**Three boundaries, which are limits rather than defects:**
|
|
639
|
+
|
|
640
|
+
1. It cannot survive process death. If the OS kills your app while an ad is on screen, the return is a cold start and nothing links it to the ad.
|
|
641
|
+
2. It depends on `setAppActive` being reported faithfully — an obligation `0.1.0` already imposes for staleness re-evaluation, so not a new one. `AdsProvider` does it for you unless you pass `trackAppState={false}`.
|
|
642
|
+
3. A browser opened by an ad without a preceding click event is undetectable, because the click is the only signal the provider gives.
|
|
643
|
+
|
|
644
|
+
### Validity window
|
|
645
|
+
|
|
646
|
+
App-open expires after **30 minutes** by default, configurable from 1 minute to 1 hour via `appOpenValidityMs` — shorter than the hour the other full-screen formats get.
|
|
647
|
+
|
|
648
|
+
Worth being explicit: Google expires app-open ads after **four hours**, which is *longer* than this package's general window. The shorter window is a product decision, not a provider limit. An app-open ad shows at the most prominent moment your app has, and a creative cached for an hour is stale by then; thirty minutes still spares a returning user the spinner of a cold load.
|
|
649
|
+
|
|
650
|
+
### Store policy
|
|
651
|
+
|
|
652
|
+
An app-open ad **must not block the user from entering your app, and must be dismissible.** The package enforces neither — presentation belongs to the provider — and a violation is the publishing app's responsibility. App-open is also the most visible placement a store reviewer will encounter, so reflect it in your review notes.
|
|
653
|
+
|
|
654
|
+
---
|
|
655
|
+
|
|
656
|
+
## Why an ad did not show
|
|
657
|
+
|
|
658
|
+
Three independent gates can be closed. When more than one is, the reported reason follows this precedence, **most immovable first**:
|
|
659
|
+
|
|
660
|
+
| Priority | Gate | Reasons |
|
|
661
|
+
|---|---|---|
|
|
662
|
+
| 1 | Platform availability | `unsupported_platform`, `provider_missing` |
|
|
663
|
+
| 2 | Enablement | `disabled`, `undecided` |
|
|
664
|
+
| 3 | Consent | `consent_required`, `consent_undetermined`, `consent_flow_required`, `consent_flow_error` |
|
|
665
|
+
|
|
666
|
+
Full table:
|
|
667
|
+
|
|
668
|
+
| Availability | Enablement | Consent | Reported |
|
|
669
|
+
|---|---|---|---|
|
|
670
|
+
| ok | on | open | *(allowed)* |
|
|
671
|
+
| ok | on | your gate closed | `consent_required` / `consent_undetermined` |
|
|
672
|
+
| ok | on | flow not permitted | `consent_flow_required` |
|
|
673
|
+
| ok | on | flow errored | `consent_flow_error` |
|
|
674
|
+
| ok | `disabled` | any | `disabled` |
|
|
675
|
+
| ok | `undecided` | any | `undecided` |
|
|
676
|
+
| unavailable | any | any | `unsupported_platform` |
|
|
677
|
+
| provider missing | any | any | `provider_missing` |
|
|
678
|
+
|
|
679
|
+
Exactly one reason is reported — the most useful one for diagnosis, not the first one checked. An unsupported platform makes the other two unfixable, so telling you "consent required" there would send you off to build a flow that will never help.
|
|
680
|
+
|
|
681
|
+
**A fourth reason, outside the gates: `throttled`.** The gates above all mean "you may not ask". Throttling means "you may, but not yet" — the placement asked too recently (see [Request throttling](#request-throttling)). It is checked after the gates, because a closed gate is the more useful thing to report, and it carries `retryAfterMs` where a gate carries a reason.
|
|
682
|
+
|
|
683
|
+
| Symptom | Likely cause |
|
|
684
|
+
|---|---|
|
|
685
|
+
| Placement sits at `throttled` after a navigation | A banner inside a remounting subtree, hitting the 60s floor |
|
|
686
|
+
| Placement sits at `undecided` forever | Enablement never resolved from `'undecided'` |
|
|
687
|
+
| Placement sits at `idle`, banner never appears | `AdsBanner` gated on its own state, or a zero-width container |
|
|
688
|
+
| `load` returns `blocked` | Consent gate closed |
|
|
689
|
+
| `initialize` returns `blocked` · `consent_flow_required` | The consent flow has not permitted requests yet; the package initializes by itself once it does |
|
|
690
|
+
| Status went `ready` → `blocked` after the privacy options form | The user withdrew consent; re-granting resumes with no second initialization |
|
|
691
|
+
| Status went `ready` → `blocked` after a later `requestInfoUpdate()` | The re-check failed (`consent_flow_error`); a successful re-check resumes |
|
|
692
|
+
|
|
693
|
+
**Changed in `0.2.0`:** on an unavailable platform with ads also disabled, the reported reason is now `unsupported_platform` where `0.1.0` reported `disabled`. The old outcome depended on which condition arrived last. No request counts change.
|
|
694
|
+
|
|
695
|
+
---
|
|
696
|
+
|
|
697
|
+
## Testing
|
|
698
|
+
|
|
699
|
+
The whole suite runs with **no device, no network, and no ad inventory**.
|
|
700
|
+
|
|
701
|
+
```ts
|
|
702
|
+
import { createFakeAdsEngine } from '@chipmobilesdk/rn-ads/testing';
|
|
703
|
+
|
|
704
|
+
const engine = createFakeAdsEngine();
|
|
705
|
+
const ads = createAds(config, { engine, isDevBuild: true });
|
|
706
|
+
|
|
707
|
+
await ads.initialize({ canRequestAds: true });
|
|
708
|
+
await ads.load('level_break');
|
|
709
|
+
|
|
710
|
+
expect(engine.adUnitIds()).toEqual([/* only official test units */]);
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
The fake records every call, lets you resolve or reject each load and show, and lets you emit provider callbacks in **any order** — which is how the reward-ordering and late-callback cases are tested.
|
|
714
|
+
|
|
715
|
+
```
|
|
716
|
+
engine.calls every call the package made, in order
|
|
717
|
+
engine.adUnitIds() what actually reached the provider
|
|
718
|
+
engine.liveHandles() non-empty after dispose means a leak
|
|
719
|
+
engine.emit(id, ev) you control ordering
|
|
720
|
+
engine.listenerCount() must reach zero after cleanup
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
Consent is scripted the same way. The privacy options form can be held open, so `busy`, a consent update, or `dispose()` can happen while it is on screen:
|
|
724
|
+
|
|
725
|
+
```ts
|
|
726
|
+
const engine = createFakeAdsEngine({
|
|
727
|
+
consentInfo: { status: 'obtained', canRequestAds: true, privacyOptions: 'required' },
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
engine.setPrivacyOptionsFormResult({ status: 'required', canRequestAds: false }); // withdraws
|
|
731
|
+
const held = engine.holdPrivacyOptionsForm(); // next form stays open
|
|
732
|
+
held.close({ status: 'obtained', canRequestAds: true }); // ...until you settle it
|
|
733
|
+
held.fail(); // or fail it
|
|
734
|
+
engine.failPrivacyOptionsForm(); // every later form rejects
|
|
735
|
+
engine.failConsentInfoRead(); // the re-read after a failure rejects too
|
|
736
|
+
engine.consentCalls // includes 'showPrivacyOptionsForm'
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
On device, verify with Google's official test ads only. `npm run android`, then exercise each `Ads · …` screen in the demo app.
|
|
740
|
+
|
|
741
|
+
---
|
|
742
|
+
|
|
743
|
+
## Release checklists
|
|
744
|
+
|
|
745
|
+
### Before you ship
|
|
746
|
+
|
|
747
|
+
- [ ] `npm run validate:ads` is green (lint, test, typecheck, pack dry-run)
|
|
748
|
+
- [ ] `npm run android` and `npm run build:apk` both succeed
|
|
749
|
+
- [ ] Merged manifest inspected; permissions match the table above and nothing else is new
|
|
750
|
+
- [ ] Zero production ad requests observed from a `development` build — captured from logcat as release evidence
|
|
751
|
+
- [ ] `environment: 'production'` is set only in release builds
|
|
752
|
+
- [ ] No production ad unit, credential, or secret in this package
|
|
753
|
+
|
|
754
|
+
### AdMob Console (cannot be done in code)
|
|
755
|
+
|
|
756
|
+
- [ ] Android app registered under the correct package / application ID
|
|
757
|
+
- [ ] A production ad unit created per placement, in the matching format
|
|
758
|
+
- [ ] Account status and payment details verified
|
|
759
|
+
- [ ] `app-ads.txt` published if you use it
|
|
760
|
+
- [ ] Consent/privacy messaging configured if you use it
|
|
761
|
+
- [ ] Content rating (`maxAdContentRating`) reviewed — ad content affects your app's age rating
|
|
762
|
+
- [ ] Policy, traffic, and serving restrictions checked
|
|
763
|
+
- [ ] **No self-clicking, ever**
|
|
764
|
+
|
|
765
|
+
### Google Play Console
|
|
766
|
+
|
|
767
|
+
- [ ] App declared as containing ads
|
|
768
|
+
- [ ] **Data safety** updated: the Google Mobile Ads SDK collects the Advertising ID, coarse device and app information, and ad interaction signals on your behalf — this package collects and stores nothing of its own
|
|
769
|
+
- [ ] Privacy policy covers advertising data
|
|
770
|
+
- [ ] Target audience and content completed
|
|
771
|
+
- [ ] Content rating questionnaire reflects ad content
|
|
772
|
+
- [ ] Families requirements met if applicable — see below
|
|
773
|
+
- [ ] Advertising ID declaration matches your target SDK and actual behavior
|
|
774
|
+
- [ ] Regional consent requirements satisfied
|
|
775
|
+
|
|
776
|
+
> **Children and families.** Ads plus advertising identifiers in an app that may reach children is a high-risk combination. This package supports `childDirected` and `underAgeOfConsent` as enforced pre-initialization inputs and documents the `AD_ID` removal path — but **it does not and cannot determine a user's age.** That determination, and the policy built on it, are yours.
|
|
777
|
+
|
|
778
|
+
---
|
|
779
|
+
|
|
780
|
+
## Known limitations
|
|
781
|
+
|
|
782
|
+
- **iOS is not implemented.** Importing and calling the package on iOS is safe and returns `unavailable` / `unsupported_platform`.
|
|
783
|
+
- **Native and rewarded-interstitial ads are deferred.** Each has its own lifecycle, view construction, or presentation policy. They are additive later — a new `AdFormat` member, a factory branch in the engine adapter, and their own official test units. (App-open shipped in `0.2.0`.)
|
|
784
|
+
- **Ad-originated return detection cannot survive process death**, and cannot see a browser an ad opened without a preceding click event. See [App open](#app-open).
|
|
785
|
+
- **Mediation across multiple ad networks is out of scope.**
|
|
786
|
+
- **Full-screen presentation, focus handling, and dismissal affordances are rendered by the provider** and outside this package's control. The package cannot make them accessible; it can only report what happened.
|
|
787
|
+
- **`impression` for full-screen formats is derived**, not provider-reported. See the asymmetry note above.
|
|
788
|
+
- **Bridge versions 16.1.0+ are unusable** under React Native 0.85. See [Compatibility](#compatibility).
|
|
789
|
+
|
|
790
|
+
## Extending to iOS
|
|
791
|
+
|
|
792
|
+
Adding iOS contributes exactly three things and changes **nothing** you have already written:
|
|
793
|
+
|
|
794
|
+
1. **`PlacementDefinition.iosAdUnitId`** — the optional field already exists in v1's type, so existing Android-only catalogs stay valid.
|
|
795
|
+
2. **A second engine adapter** behind the existing `AdsEngine` port. The port names no platform and no provider; that is what makes this additive.
|
|
796
|
+
3. **iOS entries in the private test-ad-unit table.**
|
|
797
|
+
|
|
798
|
+
Your `createAds`, `initialize`, `load`, `show`, `AdsBanner`, and every subscription keep their current signatures. The exported state, event, and error models do not change.
|
|
799
|
+
|
|
800
|
+
Still to be done at that time, none of it touching the public contract: CocoaPods linking, the AdMob application ID in `Info.plist`, the SKAdNetwork identifiers Google requires, the privacy manifest, and App Store privacy labels.
|
|
801
|
+
|
|
802
|
+
**Consent and App Tracking Transparency are two different things with a binding order.** ATT governs access to the device advertising identifier; the consent flow governs whether personalized ads may be requested at all. On iOS the ATT prompt must come first, and only then does the consent outcome mean what it means. The normalized `ConsentState` in `0.2.0` was shaped to express both mechanisms and their ordering **without changing shape** — an iOS adapter contributes a second `ConsentPort` implementation and nothing else. The tri-state enablement flag and the retry policy are entirely platform-neutral and carry no Android assumption.
|
|
803
|
+
|
|
804
|
+
## Versioning
|
|
805
|
+
|
|
806
|
+
Semantic versioning. The event catalog, error codes, state names, and result unions are public API. New formats, new platforms, and new optional configuration arrive as additive minor releases; renaming or removing any of the above is a major.
|
|
807
|
+
|
|
808
|
+
### Migrating `0.3.x` to `0.4.0`
|
|
809
|
+
|
|
810
|
+
**No existing exported union gained a member**, so no exhaustive `switch` in your code needs a new case. `0.3.0` call sites compile and behave as before, apart from the behaviour changes and fixes below.
|
|
811
|
+
|
|
812
|
+
What was added, all of it opt-in:
|
|
813
|
+
|
|
814
|
+
| Addition | Kind |
|
|
815
|
+
|---|---|
|
|
816
|
+
| `ConsentApi.showPrivacyOptionsForm()` | new method |
|
|
817
|
+
| `PrivacyOptionsFormResult` | new union: `shown`, `not_required`, `info_update_required`, `busy`, `failed`, `unavailable` |
|
|
818
|
+
| `PrivacyOptionsRequirement` | new union: `required`, `not_required`, `unknown` |
|
|
819
|
+
| `ConsentSnapshot.privacyOptionsRequirement` | new field |
|
|
820
|
+
| `FakeConsentCall` (`./testing`) | gained `{ kind: 'showPrivacyOptionsForm' }` |
|
|
821
|
+
| Fake engine (`./testing`) | `setPrivacyOptionsFormResult`, `failPrivacyOptionsForm`, `holdPrivacyOptionsForm`, `failConsentInfoRead`; `consentInfo` takes `privacyOptions` |
|
|
822
|
+
|
|
823
|
+
Two places need a touch only if you have them:
|
|
824
|
+
|
|
825
|
+
- A hand-written `ConsentSnapshot` object literal, typically a test mock, needs `privacyOptionsRequirement`.
|
|
826
|
+
- A hand-written implementation of `ConsentApi` needs `showPrivacyOptionsForm`.
|
|
827
|
+
|
|
828
|
+
No new event type was added. A consent change caused by the form arrives through `consent.subscribe`, as an `initialization` event when the status moves, and as `unavailable` for a load it cancels.
|
|
829
|
+
|
|
830
|
+
**Three behaviour changes:**
|
|
831
|
+
|
|
832
|
+
- **`initialize()` waits for the package's consent flow, once you use it.** After any `ads.consent.*` call, `initialize()` on a flow that does not permit requests (declined, not yet answered, or failed) returns `{ status: 'blocked', reason: 'consent_flow_required' | 'consent_flow_error' }` and initializes nothing natively. `0.3.0` returned `ready` and initialized the provider while `load` refused. The flow opening later initializes the provider itself, once. An app that runs update → form → `initialize()` gets `ready` exactly as before when the user consents; the difference is only for a user who did not. Apps that never call `ads.consent.*` are unaffected.
|
|
833
|
+
- **A consent gate that closes while the instance is `ready` now moves it to `blocked`.** `0.3.0` kept `ready` while `load` already refused, so a mounted banner went on requesting. This happens on a withdrawal through the new form, and also when a later `requestInfoUpdate()` fails. In-flight loads now settle with the block reason, loaded-but-unshown ads are released, and an ad on screen is left alone.
|
|
834
|
+
- **Reopening a gate on an instance that was already initialized no longer initializes the provider again.** That applies to the consent flow and to your own gate through `updatePrivacy`. `0.3.0` made a second native initialization on `ready → blocked → reopened`. One exception keeps the old path: if an init-time flag (`childDirected`, `underAgeOfConsent`, `maxAdContentRating`) changed in between, a real initialization still runs, because that is the only way the flag can apply.
|
|
835
|
+
|
|
836
|
+
**One fix that needs no action:** a `load()` in flight when ads are turned off, turned `undecided`, or the instance is disposed now **settles** — with `disabled`, `undecided`, or `unavailable` / `provider_missing` respectively. `0.3.0` released the resources but never resolved the promise, so code awaiting it waited forever; and a handle the provider returned after the disable was adopted and went on loading. Now it is disposed.
|
|
837
|
+
|
|
838
|
+
### Migrating `0.2.x` to `0.3.0`
|
|
839
|
+
|
|
840
|
+
**One compile-time change.** If you have an exhaustive `switch` over any of these, add the new case or a `default`:
|
|
841
|
+
|
|
842
|
+
| Union | New member |
|
|
843
|
+
|---|---|
|
|
844
|
+
| `PlacementState` | `'throttled'` |
|
|
845
|
+
| `LoadResult` | `{ status: 'throttled', retryAfterMs }` |
|
|
846
|
+
| `AdEventType` | `'request_throttled'` |
|
|
847
|
+
|
|
848
|
+
**One behaviour change, and it is the point of the release.** Banner placements now have a 60s floor between requests ([Request throttling](#request-throttling)). A banner inside a list row, a tab, or a view the user swipes between will report `'throttled'` after a quick navigation where `0.2.x` silently issued another request — the silent request being the defect this fixes.
|
|
849
|
+
|
|
850
|
+
Full-screen formats are unchanged: their default floor is `0`, so request counts for `interstitial`, `rewarded` and `app_open` are identical to `0.2.x` unless you configure a floor.
|
|
851
|
+
|
|
852
|
+
**What has not changed:** no frequency cap was added, and none is planned. Pacing stays entirely yours.
|
|
853
|
+
|
|
854
|
+
### Migrating `0.1.0` to `0.2.0`
|
|
855
|
+
|
|
856
|
+
**What you must do: one thing, and it is compile-time only.**
|
|
857
|
+
|
|
858
|
+
If you have an exhaustive `switch` over any of these unions, add the new cases or a `default`:
|
|
859
|
+
|
|
860
|
+
| Union | New members |
|
|
861
|
+
|---|---|
|
|
862
|
+
| `AdFormat` | `'app_open'` |
|
|
863
|
+
| `InitStatus` | `'undecided'` |
|
|
864
|
+
| `PlacementState` | `'undecided'` |
|
|
865
|
+
| `PrivacyBlockReason` | `'consent_flow_required'`, `'consent_flow_error'` |
|
|
866
|
+
| `AdsErrorCode` | `'undecided'`, `'ad_originated_return'`, `'consent_flow_required'`, `'consent_flow_failed'`, `'retry_out_of_bounds'` |
|
|
867
|
+
|
|
868
|
+
There is no runtime equivalent. If you change nothing else you get identical behavior and an identical number of requests — proven by a test in this package, not merely promised.
|
|
869
|
+
|
|
870
|
+
**What you may ignore, entirely:**
|
|
871
|
+
|
|
872
|
+
- Consent — nothing starts unless you call `ads.consent.*`.
|
|
873
|
+
- The `'undecided'` enablement state — `enabled: true` and `enabled: false` mean exactly what they meant.
|
|
874
|
+
- App-open — a format you do not have to declare.
|
|
875
|
+
- Retry — **off** unless you supply `retry`.
|
|
876
|
+
|
|
877
|
+
**One behavior change requiring no action:** on an unavailable platform with ads also disabled, the reported reason is now `unsupported_platform` rather than `disabled`. The `0.1.0` outcome depended on which condition arrived last. Request counts are unchanged.
|
|
878
|
+
|
|
879
|
+
**Worth adopting if it applies to you:** if you sell an ad-removal tier, `enabled: 'undecided'` and the typed `setEnabled` result together close a real defect — see [Turning ads off](#turning-ads-off--three-states).
|