Seulement des vidéos récompensées AdMob. Le crédit est décidé par le serveur, sur l'appel signé de Google (SSV).
L'app ne fait qu'attendre la confirmation.
App Google AdMob API
├─ charge la pub (bloc /config → ad_units)
├─ setServerSideOptions(userId = users.id, customData = "placement|ref|nonce")
├─ montre la pub ──────────────────► pub vue jusqu'au bout
│ └─ GET /ads/ssv?…&custom_data&user_id&transaction_id&…&signature&key_id ─►
│ ├─ verify_ssv (ECDSA) 403 si faux
│ ├─ grant() : règles + ad_rewards + XP
│ └─ 200 toujours si signature bonne
└─ GET /me/ads/claims/<nonce> toutes les 1 s, 15 fois au plus ─────────────► {granted, xp, xp_total, level}
| Variable | Défaut | Rôle | Lu dans |
|---|---|---|---|
ADS_ENABLED |
false |
coupe tout crédit ; l'app ne charge rien | services/ads.py, routers/ads.py, /config |
XP_AD |
500 | XP d'une pub daily |
services/ads.py |
ADS_PER_DAY |
3 | pubs daily par jour (daily_progress.ads_watched) |
services/ads.py, routers/me.py (ads_max) |
XP_AD_CHALLENGES |
300 | XP de la pub challenges |
services/ads.py |
ADS_MAX_PER_DAY |
6 | plafond tous emplacements (lignes ad_rewards du jour) |
services/ads.py |
AD_UNIT_REWARDED_IOS / _ANDROID |
blocs de test Google | blocs envoyés à l'app | /config |
FEED_CARD_AD_XP_ENABLED / _AFTER |
true / 5 |
carte du fil (logique dans l'app) | /config |
ADS_TRUST_CLIENT |
false |
ouvre POST /me/ads/reward (crédit sans Google). Développement seulement |
routers/ads.py |
En build debug, l'app utilise toujours les blocs de test de Google (app/lib/api/ads.dart, _testUnits), quoi que
dise /config.
services/ads.py, verify_ssv)| Point | Détail |
|---|---|
| Message signé | la chaîne de requête brute (request.url.query) jusqu'à &signature= exclu. Google place signature et key_id en dernier |
| Algorithme | ECDSA P-256 / SHA-256, signature DER en base64 « web safe » (padding ajouté) |
| Clés | https://www.gstatic.com/admob/reward/verifier-keys.json → {keyId: clé PEM} |
| Cache | en mémoire par worker, 24 h (KEYS_TTL_S) ; key_id inconnu → un rechargement forcé, puis refus |
| Refus | paramètres absents, clé inconnue, signature invalide → 403 |
| Accepté | 200 dans tous les cas, même si grant refuse (sinon Google réessaie) ; refus journalisé en info |
Ce qui n'est pas contrôlé : ad_unit, reward_amount, reward_item, timestamp. L'XP vient du barème serveur,
jamais de Google. Le rejeu est bloqué par transaction_id (voir idempotence).
grant)Après la signature : custom_data doit faire 3 parties, placement ∈ {daily, challenges}, nonce non vide ;
user_id doit être un compte existant non supprimé. Puis, dans l'ordre :
| # | Contrôle | Refus |
|---|---|---|
| 1 | transaction_id vide |
« transaction absente » |
| 2 | ad_rewards[transaction_id] existe |
renvoie la ligne existante, aucun nouveau crédit |
| 3 | ADS_ENABLED faux |
« pubs désactivées » |
| 4 | user.subscribed |
« abonné Slidz+ » |
| 5 | lignes ad_rewards du joueur depuis son minuit local ≥ ADS_MAX_PER_DAY |
« plafond du jour » |
| 6a | daily : daily_progress.ads_watched ≥ ADS_PER_DAY |
sinon ads_watched += 1, ref = nonce, XP = XP_AD |
| 6b | challenges : défis du jour pas tous faits (challenges_summary) |
sinon ref = date du jour, XP = XP_AD_CHALLENGES |
| 7 | insertion ad_rewards ; IntegrityError → rollback |
« déjà récompensé » |
| 8 | award_xp(source="ad", play=False) puis commit |
— |
« Jour » = journée du joueur (users.timezone, envoyé par l'en-tête X-Timezone, Paris par défaut). Le ref envoyé
par l'app est ignoré : le serveur le remplace.
XP seulement : award_xp crédite xp_total, recalcule le niveau, écrit xp_events (source = ad). play=False :
pas compté dans l'XP de jeu du jour. Aucun point de saison.
| Niveau | Mécanisme |
|---|---|
| Même pub rappelée par Google | ad_rewards.id = transaction_id AdMob (clé primaire, 80 caractères) |
| Un bonus de défis par jour | contrainte unique (user_id, placement, ref) avec ref = date |
daily |
ref = nonce (aléatoire, 24 hex, Random.secure) : l'unicité ne limite pas, c'est ads_watched qui compte |
| Suivi par l'app | GET /me/ads/claims/<nonce> lit ad_rewards par (user_id, nonce) ; colonne nonce indexée |
app/lib/api/ads.dart)watch() : pas de pub si ads_enabled faux, pas de compte, ou pas de bloc._readyFor), puis rechargée.adClaim à 1 s d'intervalle, puis un essai de POST /me/ads/reward.Session.adsAvailable : ads_enabled, pas abonné, daily.adsWatched < daily.adsMax.maxAdContentRating = PG, AgeRestrictedTreatment.teen, pas d'ATT ni d'identifiant publicitaire.POST /me/ads/reward aussi en production : réponse 403 (normal,ADS_TRUST_CLIENT faux). Si le rappel de Google arrive après 15 s, l'XP est créditée mais l'app affiche « pas deads_watched) et dépasser la limite d'une pub.key_id inconnu force un téléchargement des clés de Google à chaque appel : un appel non signé mais avec unkey_id bidon déclenche une requête sortante.ADS_MAX_PER_DAY n'est pas envoyé à l'app : elle ne connaît que ADS_PER_DAY.CLAUDE.md dit les pubs « derrière des flags (à faux) » : ADS_ENABLED=1 en production depuis le 7 octobre 2026.Code : back/app/services/ads.py (verifier_keys, verify_ssv, parse_custom_data, grant),
back/app/routers/ads.py (admob_ssv, ad_claim, ad_reward_dev), back/app/models.py (AdReward),
back/app/services/progress.py (award_xp), app/lib/api/ads.dart (watch), app/lib/session.dart
(adsAvailable)