one_viewr_flutter
Client Flutter embarquable pour Oneviewr CRM : enregistrer/identifier un
contact (identify), envoyer des événements de tracking (track,
signupProgress pour suivre une inscription), et faire
remonter un token push FCM/APNs (registerPushToken) — port direct du SDK JS
public @oneviewr/js, avec la même philosophie de
sécurité (une clé publique, non secrète, jamais la clé marchand
X-Merchant-Key) et les patrons d'architecture d'
onex_client_plugin_flutter (façade statique
unique, résolution d'environnement, client REST séparé de l'état, stockage
local avec repli mémoire).
Depuis la Phase 5 du canal IN_APP, ce paquet affiche aussi les messages
in-app d'un Broadcast/Journey (checkInAppMessages) — voir la section
dédiée plus bas et "Rupture de doctrine" ci-dessous.
Installation
dependencies:
one_viewr_flutter:
path: ../one_viewr_flutter # ou une source git/pub une fois publié
Démarrage (boot)
import 'package:one_viewr_flutter/one_viewr_flutter.dart';
await OneViewr.boot(
publicKey: 'pk_votre_cle_publique', // REQUIS — voir "Sécurité" plus bas
env: OneViewrEnv.prod, // prod (défaut, https://api.oneviewr.com) | local (http://localhost:8081)
// apiBase: '', // URL absolue hors table, prioritaire sur env
);
apiBase explicite > env > défaut compilé (prod) — même précédence que le
SDK JS. boot doit être awaité avant tout autre appel : contrairement
au web (localStorage synchrone), charger l'identité déjà persistée sur
l'appareil est asynchrone ici.
identify — enregistrer/identifier un contact
final result = await OneViewr.identify(
externalId: 'user-12345', // l'identifiant de VOTRE système — au moins un des trois requis
email: 'client@exemple.com',
phone: '+221771234567',
language: 'fr', // REQUIS — voir plus bas
firstName: 'Awa',
lastName: 'Diop',
birthDate: '1990-05-12', // AAAA-MM-JJ — voir plus bas
country: 'SN', // code pays ISO à 2 lettres
accountCreation: '2026-09-12T08:30:00Z', // création du compte, UTC ISO 8601
traits: {'gender': 'FEMALE', 'signupSource': 'landing-page'}, // fusionné dans les attributs du contact
hash: 'a3f9…', // voir "Vérification d'identité" plus bas
);
birthDate : AAAA-MM-JJ (une date ISO complète, comme
DateTime.toIso8601String(), est acceptée : seule la date telle qu'écrite est
gardée). Elle est rangée là où la lisent l'import CSV, la fiche client et les
parcours d'anniversaire (birthday). Une date mal formée lève ArgumentError
avant tout appel réseau. Les autres champs métier (country, region, city,
district, accountValidatedAt, accountCreation, plan) et traits vont
dans les attributs du contact ; accountCreation/accountValidatedAt ne
servent de date de création du contact qu'en UTC ISO 8601.
Au moins un de externalId/email/phone est requis — sinon identify
échoue explicitement, côté client, sans appel réseau. Le contact résultant
est mémorisé sur l'appareil (shared_preferences, avec repli mémoire si le
stockage est bloqué) : les appels track/registerPushToken suivants n'ont
pas besoin de répéter les identifiants.
language est obligatoire (code ISO 639-1, ex. 'fr', 'en') — ce
n'est pas un champ décoratif : c'est exactement la valeur lue côté backend
(JourneyActivitiesImpl#resolveContactLanguage,
Contact.attributes.language) pour décider dans quelle langue un Journey
envoie un message à ce contact.
track — envoyer un événement
await OneViewr.track('page_viewed', properties: {'path': '/pricing'});
await OneViewr.track('purchase', properties: {'amount': 42}, source: 'ios_app');
Nécessite un identify préalable (dans la session courante ou une
précédente, via le stockage persistant) — sinon échoue explicitement avant
tout appel réseau. Un eventId (UUID v4) est généré automatiquement pour
l'idempotence côté serveur.
Positions
L'application hôte fournit elle-même les positions ; le SDK ne demande aucune permission, ne lit aucune position et ne tourne pas en arrière-plan.
final result = await OneViewr.trackLocation(
latitude: 4.05080,
longitude: 9.70450,
accuracyMeters: 18.5,
source: 'gps',
);
await OneViewr.trackLocations(samples); // lots de 100 automatiquement
Après une coupure réseau, les échantillons sont conservés au maximum 24 heures
dans une file AES-GCM plafonnée à 500 éléments. La clé est conservée dans le
stockage sécurisé de la plateforme et le contenu chiffré dans les préférences.
Le résultat indique accepted, queued, les codes rejected et persisted.
Les risques résiduels de toute file locale sont un crash avant persistance, une
sauvegarde du téléphone ou un appareil partagé.
signupProgress — suivre une inscription étape par étape
Pour voir qui commence à s'inscrire sans finir, où chacun décroche, et le relancer par un Journey :
// 1. Dès que la personne a saisi son e-mail OU son téléphone (pas encore d'id utilisateur) :
await OneViewr.identify(phone: '+237690000000', language: 'fr');
// 2. À chaque étape (attemptId : nouveau à chaque recommencement depuis zéro) :
await OneViewr.signupProgress(step: 'phone', stepNumber: 1, attemptId: attemptId);
await OneViewr.signupProgress(step: 'otp', stepNumber: 2, attemptId: attemptId,
properties: {'method': 'sms'});
// 3. Inscription terminée : le MÊME téléphone/e-mail + votre id utilisateur, puis signup
await OneViewr.identify(externalId: 'user-12345', phone: '+237690000000', language: 'fr');
await OneViewr.track('signup');
Équivaut à track('signup_progress', properties: {'step': …, 'step_number': …, 'attempt_id': …, ...properties}) — même contrat que la commande
signupProgress du SDK JS, mêmes règles que track (identify préalable).
step est obligatoire (un nom stable d'une version à l'autre) ; stepNumber
est son rang, à partir de 1 ; attemptId change à chaque fois que la
personne recommence depuis zéro.
identifyexigelanguage, y compris ce premier appel fait avec le téléphone ou l'e-mail seul.- La fin de l'inscription, c'est
track('signup')— jamais une propriétécompleted. Des étapes reçues après unsignupne rouvrent pas l'inscription. - Renvoyez le même téléphone ou e-mail à l'
identifyfinal avec votreexternalId: c'est ce qui rattache votre id au contact créé pendant les étapes ; unexternalIdseul créerait un doublon sans l'historique.
Source d'acquisition — d'où vient cet appareil
Activée par défaut, sans rien à appeler de plus que boot : le paquet note
d'où vient l'installation, puis envoie le premier contact (fixé une fois,
jamais remplacé) et le dernier contact (la dernière visite porteuse d'un
paramètre marketing) dans chaque identify, et le dernier contact dans
context.attribution de chaque track. Même format et mêmes règles que le
SDK JS (section « Source d'acquisition » de son README) : côté Oneviewr, la
fiche client, les segments et le rapport Acquisition les lisent de la même
façon, qu'un contact arrive par le site ou par l'application.
Ce que le paquet relève, selon la plateforme :
| Plateforme | Au premier démarrage | Ce qu'on en apprend |
|---|---|---|
| Android | Play Install Referrer : le paramètre referrer du lien qui a mené à Google Play |
UTM du lien, gclid des campagnes d'application Google Ads (la campagne exacte est retrouvée côté serveur), installation depuis la fiche du magasin (google-play / organic), publicité Meta (campagne, ensemble de publicités et publicité déchiffrés côté serveur, voir plus bas), lien d'acquisition Oneviewr (ov_link_code) |
| iOS | Jeton Apple AdServices, échangé auprès d'Apple ; en option (readIosClipboard), le jeton d'un lien d'acquisition Oneviewr copié dans le presse-papiers |
Campagne, groupe d'annonces et mot-clé Apple Search Ads ; le lien d'acquisition cliqué avant l'installation |
| Web | L'adresse de la page, à chaque démarrage (comme le SDK JS) | UTM et identifiants de clic de l'URL |
Aucune permission ni consentement au suivi publicitaire (ATT) n'est demandé :
ni le referrer Google Play ni le jeton AdServices n'utilisent d'identifiant
publicitaire. (Seule exception, à activer, la lecture du presse-papiers iOS
pour les liens d'acquisition différés : iOS 16 demande alors d'autoriser le
collage — voir plus bas.) Cette recherche tourne en arrière-plan — boot ne l'attend
pas — et un échec passager (service Google Play indisponible, réseau coupé)
est retenté aux démarrages suivants, trois fois au plus.
Liens profonds — recordDeepLink
Le paquet ne capte pas lui-même les liens qui ouvrent l'application : c'est
le système qui les livre à votre application, et elle a déjà son moyen de les
lire. Transmettez-les après boot, y compris celui qui a lancé
l'application :
await OneViewr.boot(publicKey: 'pk_…');
// Paquet app_links : le flux livre aussi le lien de lancement.
AppLinks().uriLinkStream.listen(OneViewr.recordDeepLink);
Avec go_router, le lien passe par redirect (un lien relatif comme
/promo?utm_source=… convient) ; les navigations internes, sans paramètre
marketing, sont ignorées :
GoRouter(
redirect: (context, state) {
OneViewr.recordDeepLink(state.uri);
return null;
},
routes: [...],
);
Pour que le système livre les liens https:// à l'application plutôt qu'au
navigateur, déclarez-les une fois (configuration de l'application, pas du
paquet) :
- Android (App Links) : un
intent-filterandroid:autoVerify="true"sur votre domaine dansAndroidManifest.xml, et le fichier/.well-known/assetlinks.jsonpublié sur ce domaine ; - iOS (liens universels) : la capacité Associated Domains
(
applinks:votre-domaine) et le fichier/.well-known/apple-app-site-associationpublié sur ce domaine ; - un schéma propre (
monapp://…) marche sans fichier sur le domaine, mais une régie ne l'accepte pas toujours comme lien d'annonce.
Les liens de vos annonces portent alors vos paramètres, par exemple
https://app.boutique.com/promo?utm_source=meta&utm_campaign={{campaign.id}}.
Si l'application n'est pas installée, le même lien ouvre votre site (le SDK
JS en relève la source) ou mène au magasin.
Ce que fait recordDeepLink du lien : il ne lit que les paramètres utm_*
et les identifiants de clic (gclid, gbraid, wbraid, fbclid,
ttclid, msclkid), écarte toute valeur qui ressemble à une adresse e-mail
ou à un numéro de téléphone, et garde le chemin sans la requête comme page
d'arrivée (pour un schéma propre, l'hôte en fait partie :
monapp://produit/42 → /produit/42). Un lien porteur de paramètres
marketing devient le dernier contact ; il devient aussi le premier s'il
arrive avant que celui-ci soit fixé et que l'installation ne vienne d'aucune
campagne. Un lien sans paramètre marketing est ignoré. Aucun appel réseau :
le dernier contact part avec le track suivant.
Un lien d'acquisition Oneviewr (https://onvw.link/AbC12345,
https://go.boutique.com/l/AbC12345) est un signal à lui seul, même sans
utm_* : son code est relevé et le serveur en déduit la source, le canal et
la campagne. Pour savoir en plus où il mène dans l'application, voir la
section suivante.
Liens d'acquisition Oneviewr — deferredDeepLink et resolveDeepLink
Un lien créé dans Acquisition › Liens peut mener à un écran précis de
l'application (/produit/42). Deux cas, deux méthodes — ce paquet ne navigue
jamais lui-même : il vous rend la destination, votre routeur décide.
1. L'application n'est pas encore installée : l'utilisateur clique, passe par le magasin, installe, ouvre. Au premier lancement :
await OneViewr.boot(publicKey: 'pk_…', readIosClipboard: true);
final link = await OneViewr.deferredDeepLink();
if (link != null && link.isResolved && link.appRoute != null) {
router.go(link.appRoute!); // ex. /produit/42 — link.appParams, link.fallbackUrl…
}
deferredDeepLink rend null quand il n'y a rien à remettre (installation
organique, lien déjà remis — il ne l'est qu'une fois —, source d'acquisition
désactivée) ou quand la recherche n'est pas finie au bout de timeout (5 s par
défaut ; rien n'est perdu, rappelez-la plus tard). Sinon, un DeepLink dont
status vaut resolved, notFound, expired ou invalid — les échecs sont
rendus tels quels pour que vous puissiez les consigner.
- Android : le serveur ajoute
ov_link_codeaureferrerdu lien vers Google Play, que le Play Install Referrer relit au lancement. Silencieux, sans permission. Google Play seulement (ni Huawei, ni Amazon, ni installation hors magasin). - iOS : la page de redirection copie un jeton signé dans le presse-papiers
avant d'envoyer vers l'App Store, que l'application relit au premier
lancement. Désactivé par défaut (
readIosClipboard: false) : depuis iOS 16, lire le presse-papiers affiche « Autoriser le collage ? », et une application qui n'utilise pas ces liens ne doit pas l'infliger à ses utilisateurs. Activez-le si vos liens mènent à l'App Store. Seul un texte de la forme exacte d'un jeton Oneviewr est gardé — tout autre contenu est ignoré et ne quitte jamais l'appareil —, et ce jeton est ensuite effacé du presse-papiers. Le jeton expire dix minutes après le clic : une installation plus tardive n'est pas rattachée au clic (statutexpired).
Au mieux, jamais garanti. Refuser l'invite de collage ne se distingue pas d'un presse-papiers vide (limite d'iOS), un presse-papiers écrasé entre-temps fait perdre le jeton, et Android ne couvre que Google Play. Prévoyez toujours un parcours d'arrivée par défaut. Le serveur compte chaque résolution et son statut : le taux de résolution est visible dans les statistiques du lien.
L'attribution de l'installation ne dépend pas de cet appel : le code du lien
part avec identify (premier contact) que vous appeliez deferredDeepLink ou
non, et readIosClipboard suffit à l'activer sur iOS.
2. L'application est déjà installée : le lien universel / App Link l'ouvre
directement avec https://boutique.onvw.link/AbC12345, que votre routeur ne
connaît pas. Traduisez-le :
AppLinks().uriLinkStream.listen((uri) async {
final link = await OneViewr.resolveDeepLink(uri);
if (link != null && link.isResolved && link.appRoute != null) {
router.go(link.appRoute!);
}
});
resolveDeepLink rend null si uri n'est pas un lien d'acquisition Oneviewr
(/l/{code} sur n'importe quel domaine, /{code} sur onvw.link et ses
sous-domaines — un chemin nu ailleurs est une route de votre application), si
le serveur n'a pas pu répondre, ou si boot(attribution: false) : rien n'est
envoyé avant le consentement. Il relève aussi le lien comme source
d'acquisition (comme recordDeepLink). Pour que le système livre ces liens à
l'application, déclarez votre sous-domaine Oneviewr (applinks:boutique.onvw.link
sur iOS, intent-filter autoVerify sur Android) — l'écran Réglages ›
Application mobile d'Oneviewr dit quoi renseigner.
Ordre de décision du premier contact
- le lien d'acquisition Oneviewr cliqué avant l'installation, quand l'installation ne le porte pas déjà (iOS : jeton du presse-papiers) ;
- sinon l'installation, si elle vient d'une campagne (clic publicitaire, campagne nommée, support payant — dont le lien Oneviewr d'Android) ;
- sinon le premier lien marqué reçu avant la décision ;
- sinon l'installation depuis la fiche du magasin, sans campagne ;
- sinon une visite directe, datée du premier démarrage.
Tant que la décision n'est pas prise, identify n'envoie aucune source :
le serveur prendrait sinon le dernier contact pour le premier, et il n'écrit
le premier contact d'un contact qu'une fois. identify attend au plus trois
secondes la fin de la recherche ; au-delà, il part sans, et le paquet renvoie
le premier contact en arrière-plan (un identify avec l'identité mémorisée,
sans nom ni attribut) dès qu'il est fixé. Ce rattrapage couvre aussi une
application déjà connectée qui ne rappelle pas identify au lancement.
Désactiver (consentement)
await OneViewr.boot(publicKey: 'pk_…', attribution: false);
Rien n'est lu, rien n'est envoyé (ni recherche du lien d'acquisition différé,
ni resolveDeepLink, ni enregistrement auprès d'Apple), et ce qu'un démarrage
précédent avait mémorisé est effacé — même option que le SDK JS. Rappelez
boot avec attribution: true une fois le consentement donné. reset()
n'efface pas la source : elle décrit l'appareil, pas la personne connectée.
Relayer un outil de mesure mobile (AppsFlyer, Adjust…)
Si votre application mesure déjà ses installations avec un tel outil, fournissez sa réponse à la place de la recherche par défaut :
class AppsFlyerInstall implements InstallAttribution {
@override
Future<InstallLookup> lookup() async {
final data = await lireAttributionAppsFlyer(); // votre code
if (data == null) return const InstallRetryLater();
if (data.organique) return const InstallUnknown();
return InstallFound(AttributionTouch(
capturedAt: data.dateDuClic,
utmSource: data.regie, // 'meta', 'google', 'tiktok'…
utmMedium: 'paid_social',
utmCampaign: data.idCampagne, // c'est lui que le rapport Acquisition rapproche
));
}
}
await OneViewr.boot(publicKey: 'pk_…', installAttribution: AppsFlyerInstall());
Rapports d'installation Apple (SKAdNetwork, AdAttributionKit)
Sur iPhone, Apple ne dit pas à l'application quelle publicité Meta, Google, TikTok… a mené à l'installation. Il envoie plus tard (un à plusieurs jours après) un rapport d'installation signé à la régie : la campagne, rien sur la personne ni l'appareil. Il en envoie une copie au développeur de l'application si celle-ci déclare où l'envoyer — c'est ce qui permet à Oneviewr de compter, par régie et par campagne, les installations iOS venues des publicités, sans outil de mesure mobile.
Ce que fait le paquet : au premier lancement, une fois par installation et
en arrière-plan (boot ne l'attend pas), il appelle la méthode de « valeur de
conversion » SKAdNetwork d'Apple avec la valeur 0. Sans cet appel, Apple
n'envoie aucun rapport, ni à la régie ni à vous :
| iOS | Appel SKAdNetwork |
|---|---|
| 16.1 et plus | updatePostbackConversionValue(0, coarseValue: .low, lockWindow: false) |
| 15.4 à 16.0 | updatePostbackConversionValue(0) |
| 15.0 à 15.3 | updateConversionValue(0) |
Apple répercute lui-même cet appel dans AdAttributionKit (le cadre qui succède à SKAdNetwork depuis iOS 17.4) : les rapports des deux cadres sont couverts, sans que le paquet appelle ni lie AdAttributionKit.
Aucun consentement au suivi publicitaire (ATT) n'est demandé, et si aucune
publicité n'a mené à l'installation, Apple ignore l'appel. Un échec est
retenté aux lancements suivants, trois fois au plus. Rien n'est fait avec
attribution: false, ni sur Android ou le web.
// Un autre SDK (Meta, Firebase, AppsFlyer…) gère déjà vos valeurs de conversion :
await OneViewr.boot(publicKey: 'pk_…', appleAdAttribution: false);
Passez appleAdAttribution: false si un autre SDK gère déjà les valeurs de
conversion de votre application : sinon les deux se marchent dessus (ce
paquet pourrait remettre à 0 une valeur posée par l'autre). Les clés
ci-dessous restent utiles dans ce cas : Apple envoie la copie quel que soit le
code qui a fait l'appel.
Clés Info.plist à ajouter dans votre application — sans elles, Oneviewr
ne reçoit rien. Les deux sont nécessaires : chacune fait envoyer les copies
d'un des deux types de rapports.
<!-- SKAdNetwork : copie des rapports d'installation gagnants. -->
<key>NSAdvertisingAttributionReportEndpoint</key>
<string>https://onvw.link</string>
<!-- AdAttributionKit (iOS 17.4 et plus) : copie de ses rapports gagnants. -->
<key>AdAttributionKit</key>
<dict>
<key>AttributionCopyEndpoint</key>
<string>https://onvw.link</string>
<!-- Facultatif : aussi les rapports de réengagement (publicité qui ramène
dans l'application quelqu'un qui l'a déjà). -->
<key>OptInForReengagementPostbackCopies</key>
<true/>
</dict>
Dans l'onglet Info de Xcode, ce sont « AdAttributionKit - Postback Copy URL »
et « AdAttributionKit - Opt in for Reengagement Postback Copies ». Écrivez
l'adresse telle quelle, sans chemin : Apple n'en garde que le domaine et
envoie les rapports à https://onvw.link/.well-known/skadnetwork/report-attribution/
et https://onvw.link/.well-known/appattribution/report-attribution/. Une
seule adresse pour toutes les applications : Oneviewr reconnaît la vôtre à
son identifiant App Store, celui de l'adresse App Store renseignée dans
Oneviewr (Application mobile & liens).
Ce qui reste impossible
- iOS, installations Meta, Google Ads ou TikTok, par personne : Apple ne donne rien par utilisateur — seulement les rapports d'installation, groupés par campagne et différés (voir plus haut). Ces contacts arrivent en visite directe, sauf s'ils ouvrent ensuite l'application par un lien marqué, ou si un outil de mesure mobile est relayé.
- Android, publicités Meta sans clé enregistrée : Meta chiffre le détail du
clic dans le referrer, et la clé ne doit pas vivre dans l'application. Le
paquet transmet le bloc chiffré tel quel (
metaInstallReferrer) ; le serveur l'ouvre avec la clé que le marchand enregistre dans Oneviewr (Paramètres › Sources publicitaires › « Installations Android venues de Meta », copiée depuis Meta for Developers › Paramètres › De base › Android › Install Referrer Decryption Key) et en tire la campagne, l'ensemble de publicités et la publicité. Sans cette clé, la source (meta) et le support payant sont connus, la campagne non. - Liens profonds différés (un lien qui survit à l'installation depuis le
magasin) : voir
deferredDeepLinkplus haut — au mieux, jamais garanti. Android : Google Play seulement ; iOS : presse-papiers (invite de collage d'iOS 16, jeton valable dix minutes). Pour les liens qui ne sont pas des liens d'acquisition Oneviewr, le paquet n'a pas de mécanisme différé : seul le referrer Google Play les porte.
Tester sans magasin — debugSimulateInstall, debugAttributionState
Deux outils de développement pour vérifier l'attribution et le routage sans
publier l'application ni l'installer depuis un magasin. Ils lèvent
StateError dans une build de production (--release).
// Rejoue un premier lancement : installation venue d'une campagne…
final state = await OneViewr.debugSimulateInstall(
playReferrer: 'utm_source=test&utm_medium=cpc&utm_campaign=essai',
);
// …ou d'un lien d'acquisition Oneviewr cliqué avant l'installation.
await OneViewr.debugSimulateInstall(linkCode: 'AbC12345');
final link = await OneViewr.deferredDeepLink(); // remis une fois, comme en vrai
print(OneViewr.debugAttributionState()); // premier et dernier contact, lien
playReferrer: lereferrerd'une installation Google Play. Il passe par le même lecteur que le vrai (UTM,gclid, publicité Meta,ov_link_code), sur Android comme sur iPhone ou simulateur.linkCode: le code d'un lien existant dans Oneviewr (Acquisition › Liens), résolu par le vrai serveur ; à défaut, l'ov_link_codedu referrer.- Sans argument : une installation sans campagne.
- La simulation remplace durablement ce que l'appareil savait de son installation ; l'identité connectée ne bouge pas. Le serveur ne garde que le premier premier contact d'un client : pour le voir sur une fiche, simulez avec un client de test neuf.
Liens qui ouvrent l'application déjà installée : resolveDeepLink et
recordDeepLink acceptent n'importe quel Uri. Pour tester toute la chaîne
(système, application, routeur), faites ouvrir le lien par le système :
# Android, émulateur ou appareil branché (le paquet force l'application)
adb shell am start -W -a android.intent.action.VIEW -d "https://boutique.onvw.link/AbC12345" com.exemple.app
# Simulateur iOS (domaine associé déclaré dans l'application)
xcrun simctl openurl booted "https://boutique.onvw.link/AbC12345"
Un lien de campagne ordinaire se teste de la même façon
(https://app.exemple.com/promo?utm_source=test) : recordDeepLink en fait
le dernier contact, visible dans debugAttributionState.
Mise en place côté application
Le paquet embarque désormais du code natif (android/, ios/) : après l'avoir
ajouté ou mis à jour, reconstruisez l'application (un simple hot
restart ne charge pas le code natif — la recherche est alors retentée au
démarrage suivant). Sur iOS, pod install (ou Swift Package Manager) ajoute les
frameworks AdServices et StoreKit. Aucune permission à déclarer ; seules
les clés Info.plist des rapports d'installation Apple sont à ajouter (voir
plus haut).
registerPushToken — enregistrer un token push
await OneViewr.registerPushToken(fcmToken); // platform déduite automatiquement (iOS/Android/web)
// Identifiant d'appareil imposé plutôt que celui du paquet — `FutureOr`, donc
// un Future convient tel quel, sans l'attendre vous-même :
await OneViewr.registerPushToken(
fcmToken,
deviceId: FirebaseInstallations.instance.getId(),
);
deviceId identifie l'appareil, pas le contact (ContactPushToken côté
backend) : c'est ce qui permet à une rotation de token FCM — normale,
périodique — de mettre à jour la ligne existante au lieu d'en créer une
nouvelle à chaque fois.
Le laisser de côté est le cas normal : le paquet envoie alors le sien,
généré une fois puis persisté, qui survit à reset() (il décrit l'appareil,
pas la personne connectée dessus). Ne le renseignez que pour imposer le vôtre
— identifiant de parc, device_info_plus, installation Firebase.
Son type est un FutureOr<String>? et non un String? parce que ces sources
sont asynchrones : sans cela vous devriez les attendre vous-même avant
d'arriver ici, donc les résoudre même lorsque l'appel échoue plus tôt sur une
identité manquante. La valeur est résolue au moment de l'appel, une fois
les gardes passées. Une chaîne vide vaut « pas d'identifiant » — et ne
retombe pas sur celui du paquet, pour ne pas vous cacher qu'une source est
vide.
Ce paquet ne gère PAS l'obtention du token lui-même : votre application doit
intégrer Firebase Cloud Messaging (projet Firebase du marchand) et appeler
getToken() elle-même, puis transmettre la valeur ici. Nécessite un
identify préalable, comme track.
Canal de notification Android — pour un affichage prioritaire
Le backend Oneviewr envoie android.priority: HIGH par défaut, mais ce
réglage ne contrôle que l'urgence de livraison (réveiller l'appareil,
contourner Doze) — pas le rang d'affichage dans le tiroir de
notifications. Ce rang dépend de l'importance du canal de notification
(NotificationChannel), un réglage propre à l'appareil que seule votre
application peut définir en le créant — Firebase ne peut pas la relever
rétroactivement une fois le canal créé.
Sans un canal explicitement créé en importance haute, une notification
Oneviewr peut s'afficher sous celle d'une autre application (livraison,
messagerie…) même si elle est arrivée après — l'OS ne trie pas uniquement par
heure d'arrivée entre applications. Créez le canal avant la première
notification, avec le même identifiant que androidChannelId (réglage
Oneviewr côté Paramètres > Fournisseurs > Firebase, "default" si non
personnalisé) :
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
const channel = AndroidNotificationChannel(
'default', // doit correspondre à androidChannelId
'Notifications',
importance: Importance.high,
);
await FlutterLocalNotificationsPlugin()
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
unregisterPushToken — désabonner un appareil
await OneViewr.unregisterPushToken(fcmToken);
Aucune identité requise. À appeler à la déconnexion, AVANT reset — voir
l'avertissement ci-dessous.
trackNotificationClick — signaler un clic sur une notification push
Ce paquet ne peut PAS observer lui-même qu'une notification a été tapée :
votre propre intégration Firebase (onMessageOpenedApp/getInitialMessage)
est le seul endroit où ce tap existe. Appelez cette méthode depuis là, avec
exactement le RemoteMessage.data reçu :
FirebaseMessaging.onMessageOpenedApp.listen((message) {
OneViewr.trackNotificationClick(message.data);
});
// Et pour un tap qui a lancé l'application depuis l'état "terminée" :
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) {
await OneViewr.trackNotificationClick(initial.data);
}
data doit porter oneviewr_source — le marqueur posé côté serveur
(FirebaseClient) sur toute notification envoyée par Oneviewr — et
oneviewr_message_id. C'est ce qui permet à une application qui envoie
aussi ses propres notifications, ou qui embarque d'autres SDK, d'appeler
cette méthode sans condition sur chaque tap : trackNotificationClick
se filtre lui-même et ne lève jamais — elle renvoie false (jamais une
exception) si data n'est pas d'origine Oneviewr, si boot/identify
n'ont jamais été appelés, ou si l'appel réseau échoue ; true seulement si
l'accusé de réception a bien atteint le backend.
⚠️ oneviewr_message_id, jamais message_id : ce dernier est un mot
réservé du protocole FCM/GCM (collision avec le champ d'identifiant du
message porté par l'enveloppe elle-même) — le SDK Firebase natif (Android en
particulier) supprime silencieusement toute clé data nommée exactement
message_id avant même que votre application ne la reçoive. Constaté en
direct : une vraie notification arrivée avec oneviewr_source intact mais
sans aucune trace de l'identifiant.
reset — oublier l'identité courante
await OneViewr.reset(); // ex. à la déconnexion de VOTRE utilisateur
⚠️ reset n'agit que sur CE terminal — il oublie l'identité stockée
localement, il n'appelle jamais le backend. Sur un appareil partagé, si vous
ne faites que reset() à la déconnexion, l'appareil reste abonné aux
notifications push du contact précédent jusqu'à ce que quelqu'un d'autre
ré-enregistre son token via registerPushToken. Pour désabonner l'appareil
immédiatement, appelez unregisterPushToken(token) avant reset().
reset() referme aussi le message in-app actuellement affiché, s'il y en
a un : il appartenait à l'identité qu'on vient d'oublier et n'a rien à faire
devant la personne suivante. Il n'est alors pas acquitté — personne ne
l'a fermé, il a été retiré de l'écran — et une vérification en cours
(checkInAppMessages) s'arrête d'elle-même plutôt que d'afficher le message
suivant à un utilisateur désormais déconnecté.
Le clientRef (identifiant d'appareil envoyé avec chaque réclamation de
message in-app) survit à reset(), lui : il décrit le terminal, pas la
personne qui l'utilise à cet instant.
checkInAppMessages — afficher les messages in-app en attente
await OneViewr.checkInAppMessages(context); // context : un BuildContext monté
Vérifie s'il existe un message in-app en attente pour l'identité courante
(canal IN_APP d'un Broadcast/Journey Oneviewr — POST /in-app-messages/pending), l'affiche (MODAL, BOTTOM_SHEET, BANNER ou FULLSCREEN,
selon ce que le marchand a composé dans l'éditeur), puis recommence jusqu'à ce qu'il
n'y en ait plus — plusieurs messages en attente sont affichés un par un,
jamais superposés.
Nécessite un identify préalable, comme track/registerPushToken — même
garde, sans file de commandes pré-boot().
Les quatre formats s'affichent sur le Navigator/Overlay racine, pas
sur celui dont vient le BuildContext que vous passez : un contexte d'écran
d'onglet a un Navigator local, et une route « plein écran » poussée là ne
couvrirait que la zone de l'onglet — votre barre de navigation resterait
par-dessus le message. FULLSCREEN est en outre posé dans un Scaffold +
SafeArea, donc jamais sous la barre d'état ni sous l'indicateur d'accueil.
Une seule vérification à la fois. Appelé pendant qu'une vérification est
déjà en cours, l'appel rend la main en silence au lieu d'en lancer une
seconde — ce n'est pas une faute de l'appelant, l'affichage demandé est déjà
en route. Ce cas est courant plutôt que théorique, puisque la méthode est
justement censée être rappelée à chaque retour au premier plan (voir
l'encadré ci-dessous) : sans ce verrou, deux boucles réclameraient chacune un
message et empileraient deux boîtes de dialogue. Un appel réellement fautif
(sans boot() ou sans identify()) continue de lever, verrou ou pas.
⚠️ Contrairement à
@oneviewr/js(le SDK web),identify()n'appelle PAScheckInAppMessages()automatiquement ici. Le SDK web peut le faire parce queidentify()n'a besoin d'aucun contexte de rendu ; sur Flutter,checkInAppMessagesa besoin d'unBuildContextmonté pour afficher la WebView (Dialog/feuille du bas), etidentify()n'en reçoit pas. C'est une contrainte de la plateforme, pas un oubli — c'est à l'app hôte d'appelercheckInAppMessages(context)elle-même, à deux moments : juste après unidentify()réussi, et à chaque retour au premier plan de l'application (AppLifecycleState.resumed) — sinon un message activé pendant que l'app est déjà ouverte ne sera jamais montré tant que l'app n'est pas relancée.
class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_afterIdentify(); // suppose que boot()/identify() ont déjà été appelés plus tôt
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) _afterIdentify();
}
Future<void> _afterIdentify() => OneViewr.checkInAppMessages(context);
}
Ce paquet gère l'affichage lui-même (WebView + Dialog/feuille du bas),
contrairement à identify/track/registerPushToken qui ne font que du
réseau — voir "Rupture de doctrine" ci-dessous. Le clic sur le bouton
d'action du message ouvre sa destination (ctaUrl) via url_launcher —
http/https/mailto/deep link personnalisé, sans configuration
supplémentaire côté hôte.
Blocs interactifs, écrans ciblés et sondages
Le plugin affiche aussi les messages « version 2 » (blocs interactifs) et
« version 3 » (vidéo YouTube) de l'éditeur : il l'annonce au serveur à
chaque demande (sdkCapabilities.compilerVersion: 3), qui ne confie jamais à
une application un message qu'elle ne sait pas afficher.
| Bloc | Dans le message |
|---|---|
| Carrousel | Diapositives précédente/suivante, en boucle, points de position |
| Compte à rebours | Jours, heures, minutes, secondes ; à zéro, le texte de fin s'affiche |
| Coupon | « Copier » met le code dans le presse-papiers du téléphone |
| Sondage | Choix unique (un rond devant chaque réponse, ● une fois choisie) ou multiple (un carré, ✓) ; « Envoyer » transmet les réponses, affiche le remerciement, puis le message se ferme seul (compté comme un clic) |
| Barre de progression | Remplie d'après la valeur du contact |
| Vidéo (fichier mp4/webm) | Lecture native, jamais automatique |
| Vidéo YouTube | Vignette de la vidéo ; un toucher l'ouvre hors de l'application (l'app YouTube si elle est installée, sinon le navigateur), compte une fois comme clic et laisse le message affiché |
Vidéo YouTube. Le message ne porte que l'identifiant de la vidéo : le
plugin construit lui-même l'adresse (https://www.youtube.com/watch?v=…) et
l'ouvre avec url_launcher hors de l'application. Sur Android 11 et plus, le
bloc <queries> de l'AndroidManifest.xml (intention VIEW sur https)
est nécessaire, comme pour le bouton d'action.
Écrans ciblés. Un message peut n'apparaître que sur certains écrans
(panier, /produits/*…). Indiquez l'écran courant ; avec un context, la
vérification repart aussitôt :
GoRouter(
redirect: (context, state) {
OneViewr.setScreen(state.uri.path, context: context);
return null;
},
routes: [...],
);
Sans setScreen, seuls les messages sans ciblage d'écran sont remis.
reset() ne l'efface pas : il décrit l'application, pas la personne.
Sondages. Les réponses partent d'elles-mêmes (answerInAppSurvey,
positions des choix seulement : le serveur relit question et libellés dans le
message). À n'appeler vous-même que si vous affichez les messages avec
showInAppMessage (paramètre onAnswer).
Sécurité du rendu
Le JavaScript est actif dans la WebView, et le reste sous contrôle :
- Le HTML du marchand ne contient jamais de script : le serveur
(
InAppHtmlSanitizer) n'en laisse passer aucun, ni aucun attributon*; les liens se limitent à sept actions fixesoneviewr-inapp:*, les comportements sont des marqueursdata-oneviewr-*inertes dont chaque valeur est vérifiée, images et vidéos ne viennent que d'https. - Le seul code qui tourne est celui du paquet, constant, compilé dans
l'application : la mesure de hauteur et les blocs interactifs. Aucune
donnée du message n'y est interpolée ; il ne touche qu'aux styles, à
aria-pressedet au texte d'éléments déjà présents — jamaisinnerHTML,eval, une URL ou le réseau (vérifié par un test). - Deux canaux seulement vers l'application (
OneviewrInAppHeight,OneviewrInAppAction), et l'application revalide tout ce qui en arrive : format fermé, longueurs bornées, positions de 0 à 5. Le reste est ignoré. - Seul un vrai toucher agit : un clic synthétique sur un lien
oneviewr-inapp:*est neutralisé. Un sondage n'est envoyé qu'une fois par affichage. - Aucune navigation hors du chargement du document et de
oneviewr-inapp:cta/dismiss: la boîte d'un message ne peut jamais afficher une autre page. - Encodage déclaré (
<meta charset="utf-8">) : les accents ne sont jamais relus en Latin-1.
Rupture de doctrine : webview_flutter et url_launcher
Ce paquet a longtemps été zéro dépendance UI — un pur client réseau,
comme @oneviewr/js. checkInAppMessages a besoin d'afficher un HTML déjà
compilé (rendu identique à l'aperçu de l'éditeur, WebView côté web comme
côté Flutter — voir le plan IN_APP, Phase 0) : ce n'est ni un oubli ni une
extension incrémentale anodine, c'est une décision délibérée d'élargir la
surface de ce paquet au-delà du réseau pur, pour ce seul canal.
webview_flutter rend le HTML ; url_launcher résout la vraie destination
du bouton d'action (ctaUrl) — jamais un href littéral dans le HTML
compilé, qui ne porte que des liens internes fermés
(oneviewr-inapp:cta/oneviewr-inapp:dismiss, interceptés par
NavigationDelegate.onNavigationRequest, sans JavaScriptChannel ni script
injecté).
Sécurité
publicKey n'est PAS un secret — elle est conçue pour être embarquée
dans une application distribuée. Générée/révoquée côté staff Oneviewr
(Paramètres → Clé publique), elle donne accès à EXACTEMENT trois actions
(identify/track/registerPushToken) pour le marchand concerné — jamais à
la lecture de données, jamais aux routes internes.
Vérification d'identité (hash)
Sans hash, n'importe qui connaissant/devinant l'externalId d'un
utilisateur pourrait appeler identify/track/registerPushToken en son
nom. Si votre application manipule des données réelles d'utilisateurs
connectés, activez la vérification d'identité côté Paramètres, puis calculez
hash sur votre propre serveur — jamais dans l'application :
hash = hex(HMAC-SHA256(identitySecret, externalId))
Exactement le même schéma que le hash du widget de chat et du SDK JS.
Limites connues (v1, documentées — pas des oublis)
- Pas de mode visiteur anonyme.
identify()avec un identifiant réel est requis avanttrack/registerPushToken, cohérent avec le modèle d'identité du CRM. track()reste synchrone vis-à-vis de son effet réseau ; les positions disposent en plus d'une file chiffrée et d'un rejeu best-effort.- Limitation de débit côté serveur (
PublicRateLimitFilter, backend) : par IP et par clé publique.429en cas de dépassement.
Build & tests
flutter pub get
flutter test
Libraries
- one_viewr_flutter
- Client Flutter public d'Oneviewr CRM :
identify(enregistrer un contact),track(événements),registerPushToken/unregisterPushToken(token push FCM/APNs).