Navigation de l'application mobile
Cette page décrit l'architecture technique de la navigation inter-applications
de l'application mobile (frontend/apps/mobile, React Native). Pour la vision
fonctionnelle des parcours, voir le module Application mobile.
Contexte
L'application mobile doit donner accès au catalogue d'applications ENT de l'utilisateur — le même que celui du portail web, à l'identique : mêmes applications, mêmes libellés, mêmes familles (sections) et mêmes pictogrammes. Une application sans écran natif reste accessible en WebView authentifiée ; le catalogue est donc évolutif sans rebuild.
La pile technique est :
- React Native 0.85 / React 19 ;
- React Navigation 7 (
native-stack+bottom-tabs) ; - une notion d'« expérience » (
src/experiences/) qui aiguille l'utilisateur vers un parcours selon son profil annuaire (RootNavigator→resolveExperience).
Principe : un modèle « Registre + Lanceur + AppShell »
La navigation inter-applications repose sur trois briques, posées au-dessus des expériences existantes.
1. Le registre d'applications — src/apps/registry.ts
Le registre décrit les applications ayant un traitement mobile spécifique
(écran natif, visibilité par profil, libellé ou icône dédiés). Il ne borne pas
le catalogue : toute autre application accordée est découverte côté ENT (cf.
pont avec le catalogue ENT). Chaque entrée est un
AppDescriptor déclaratif :
interface AppDescriptor {
id: AppId;
label: string;
icon: string;
family: AppFamily; // famille du portail web → section du lanceur
categories: AppCategory[]; // besoins couverts (métadonnée d'homologation)
capabilities: Capability[]; // notify | edit | consult | message
roles: RoleExperience[]; // visibilité (repli hors-ligne)
mount: 'native' | 'webview'; // intégration progressive
route?: string; // repli si l'ENT ne fournit pas d'adresse
entMatch: string[]; // jetons de jointure avec le catalogue ENT
comingSoon?: boolean; // tuile « bientôt » tant que non branchée
external?: boolean; // connecteur hors instance (adresse absolue)
}
Les métadonnées mobiles (mount, family, categories, roles) restent
statiques ; la visibilité et la route réelles viennent de l'ENT (cf. pont
ci-dessous).
Ajouter une entrée ici sert à porter une application en natif, pas à la rendre visible : une application inconnue du registre est de toute façon exposée (WebView) dès que l'instance l'accorde. C'est ce qui satisfait l'exigence « rester évolutive » sans nouvelle livraison du mobile.
Helpers exposés :
appsForRole(role)— applications visibles pour un profil ;appsByFamily(role)— applications regroupées par famille (repli statique du lanceur, sections vides omises) ;appsByCategory(role)— regroupement par besoin, conservé pour la matrice des capacités (homologation) ;findApp(id)— résolution par identifiant (deep-link, notification).
L'identifiant d'application est soit une clé du registre ('blog'), soit
ent:<clé technique> pour une application découverte ('ent:forum') — c'est le
même type AppId qui circule dans la navigation, l'AppShell et les deep-links.
2. Le lanceur « Mes apps » — src/apps/AppLauncherScreen.tsx
Écran présentant les applications du profil connecté en grille regroupée par
famille — les mêmes sections, dans le même ordre et avec les mêmes pastilles de
couleur que « Mes applications » du portail web. Il ne contient aucune liste en
dur : tout provient de useCatalogSections(role) (→ resolveByFamily). Le rôle est dérivé du profil annuaire via
resolveExperience (repli sur enseignant pour un profil non encore dessiné,
plutôt qu'un écran vide).
L'ouverture d'une application passe par la prop onOpenApp(app). Tant qu'une
application est marquée comingSoon, la tuile affiche un badge « Bientôt » et un
message d'attente — ce qui permet de publier le lanceur complet avant que
toutes les applications soient branchées.
3. L'AppShell — src/apps/AppShell.tsx
Enveloppe commune d'une application ouverte : en-tête homogène (retour, titre,
bouton de bascule inter-apps) et corps natif ou WebView selon app.mount.
Elle est montée comme écran de la pile racine (AppShell, paramétré par
appId), au-dessus de la navigation « socle » :
// RootNavigator (extrait)
<Stack.Screen name="Main">{() => <Themed2d />}</Stack.Screen>
<Stack.Screen name="AppShell" component={AppShell} />
Le lanceur y navigue directement : navigation.navigate('AppShell', { appId }).
4. La WebView authentifiée — src/apps/WebAppScreen.tsx
Pour une application non encore portée en natif, l'AppShell rend une WebView
pointant sur getBaseUrl() + app.route (même origine que l'API). Une adresse
absolue renvoyée par l'ENT est ramenée sur l'origine de l'instance — sauf
pour un connecteur external, dont l'hôte est significatif (même règle que le
widget « Mes applications » du web). La session est partagée sans manipulation
de jeton :
- Android : le networking React Native écrit les cookies via
ForwardingCookieHandlerdans leCookieManagerde la WebView — la session ENT y est donc déjà présente ; - iOS :
sharedCookiesEnabledexpose les cookies du jar système à WKWebView.
L'écran gère l'indicateur de chargement et un état d'erreur avec ré-essai.
5. L'AppSwitcher — src/apps/AppSwitcherScreen.tsx
Feuille (modal transparent) de bascule rapide entre applications, sans repasser
par le lanceur — c'est le « mode de navigation entre applications » du cahier des
charges. Ouverte depuis le bouton de l'en-tête de l'AppShell
(navigate('AppSwitcher', { currentAppId })), elle liste les applications
ouvertes de la session (les récents, cf. ci-dessous), met en évidence l'app
courante et remplace l'AppShell par l'app choisie
(navigation.replace('AppShell', …) → bascule, pas empilement). Tant qu'une
seule application a été ouverte, elle propose le catalogue du profil pour rester
utile.
6. Le suivi des applications ouvertes — src/apps/OpenAppsContext.tsx
L'AppShell étant unique et basculé par remplacement, on ne conserve pas
plusieurs instances vivantes : OpenAppsProvider mémorise en mémoire l'ordre
d'usage des applications (la plus récente en tête, plafonné). L'AppShell
appelle markOpened(appId) à l'ouverture ; l'AppSwitcher lit recents. La liste
se réinitialise à chaque lancement (pas de persistance).
Pont avec le catalogue ENT
Le registre statique ne décrit que des métadonnées mobiles. La liste des
applications réellement accessibles (et leur URL) provient de l'instance, via
/auth/oauth2/userinfo (entcore) — le même apps que le launcher du portail
web, déjà filtré par les droits de l'utilisateur.
src/services/entApps.ts—fetchEntApps()litapps(name/address/icon/display/appType/category…) etauthorizedActions(droits workflow). Best-effort :nullen cas d'échec.src/apps/entCatalog.ts— portage de la taxonomie du portail (apps/dashboard/src/utils/app-catalog.ts) : libellés (displayNameOf), familles et leur ordre/couleurs (familyOf,FAMILY_ORDER,FAMILY_COLORS), et exclusions (isHiddenApp:display: false,appType: SYSTEM, liste noire partagéeexplorer/timeline/auth/portal…). Une famille fournie par l'ENT prime sur le catalogue, si elle est connue — exactement comme sur le web.src/apps/catalog.ts— fusion pure registre ⊕ ENT :- jointure tolérante (
matchEnt) surentMatchvsname/address/prefixENT, en forme normalisée (minuscules, sans accents ni séparateurs) ; - apps
webviewdu registre : visibilité pilotée par l'ENT,route←address,comingSoonlevé (l'app devient ouvrable) ; - apps
native: visibilité par lesrolesstatiques (parcours dédiés inconnus de l'ENT), route interne conservée ; - applications hors registre : un descripteur est fabriqué à la volée
(
entDescriptor→ ident:<clé>,mount: 'webview', libellé/famille/icône du catalogue partagé). Une app déjà décrite par le registre n'est jamais dupliquée ; entApps === null(chargement / hors-ligne) → repli statique.
- jointure tolérante (
src/apps/appIcons.ts— pictogrammes du portail rastérisés en PNG (scripts/build-app-icons.sh, React Native ne charge pas de SVG) :iconFor()résout par identifiant du registre puis par mots-clés (portage defindMatchingPicto) ; à défaut la tuile affiche l'initiale du libellé.src/apps/CatalogContext.tsx— charge le catalogue une fois après connexion et expose les hooksuseCatalogSections(lanceur),useResolvedApps(AppSwitcher) etuseResolvedApp(AppShell).
Conséquence : le mobile affiche exactement le catalogue du portail web, et
activer une application sur l'instance la fait apparaître sans rebuild. La
jointure entMatch accepte plusieurs alias car les noms d'application varient
selon les instances (ex. Messagerie = Conversation).
Intégration progressive : mount
Le champ mount autorise une livraison incrémentale sans rupture :
mount | Usage |
|---|---|
webview | Branchement rapide d'une application web ENT existante (cookie SSO). |
native | Écran React Native dédié (ex. Carnet de liaison côté parent). |
Une application est accessible dès qu'elle est accordée (WebView, même sans
entrée au registre), puis réécrite en native au fil de l'eau — sans changer
le lanceur ni la navigation. Le portage natif est un gain d'ergonomie, jamais
une condition d'accès.
Notifications → deep-links
La configuration linking (src/navigation/linking.ts) est dérivée du
registre : chaque application est joignable via openent://app/<id>, qui
pousse l'AppShell avec le bon appId. Elle est branchée sur le
NavigationContainer (App.tsx), aux côtés d'une référence de navigation
globale (src/navigation/navigationRef.ts) utilisable hors composants :
<NavigationContainer ref={navigationRef} linking={buildLinking()}>
Le service push (src/services/push.ts) route alors l'ouverture d'une
notification vers l'application ciblée. Les deux points d'entrée sont couverts :
- app en arrière-plan (tap) :
onNotificationOpenedApp→openApp(appId); - démarrage à froid :
getInitialNotification→openApp(appId), avec une brève ré-tentative tant que la navigation n'est pas prête.
Déduire l'application de ce que l'ENT envoie vraiment
L'application attendait à l'origine une clé data.appId dans la charge utile —
qu'entcore n'envoie jamais. Le service de push
(DefaultPushNotifService) transmet en réalité :
| Champ | Contenu | Exemple |
|---|---|---|
type | type de notification, en majuscules | BLOG, SCHOOLBOOK, RBS |
event-type | événement précis | BLOG_POST_PUBLISH |
params | paramètres de la notification, sérialisés en JSON | {"resourceUri":"/forum#/view/42"} |
resource, sender, sub-resource | identifiants associés |
src/services/pushRouting.ts résout donc l'application dans cet ordre :
appId (si un module maison le pose), puis type, puis le préfixe de
event-type, puis la première section de la resourceUri. La comparaison
réutilise les jetons entMatch du registre : une application reconnue dans le
catalogue l'est aussi dans les notifications, sans seconde table à maintenir
— et aucun module n'a besoin d'ajouter un champ pour que ses notifications
ouvrent le bon écran. Quand rien ne permet de trancher, aucune application
n'est ouverte : mieux vaut rester sur l'accueil que d'ouvrir au hasard.
Socle d'écran et cache hors connexion
Deux briques transverses, introduites après le portage d'une série d'applications qui répétaient les mêmes blocs :
src/apps/ui/— palette commune, états d'écran (chargement, erreur, vide), onglets et bandeau hors connexion. Regrouper ces blocs évite qu'ils divergent, et donne un seul endroit où soigner l'accessibilité (rôlestab,tablist,alert, libellé de chargement).src/services/cache.ts+src/hooks/useCachedData.ts— la dernière réponse connue est conservée puis resservie quand le réseau manque, avec la mention de sa fraîcheur. Le réseau est toujours interrogé derrière : le cache accélère l'affichage, il ne remplace pas la donnée. La clé de cache porte l'instance — un même compte peut en changer, et les données n'ont alors rien à voir.
L'ordre appliqué à chaque écran est : cache affiché tout de suite → réponse réseau qui fait autorité → en cas d'échec, cache signalé « hors connexion », et erreur seulement s'il n'y a rien à montrer.
Couper le réseau de l'émulateur coupe aussi Metro, qui sert le bundle de
développement : l'application n'atteint même pas son premier écran. Le
comportement est donc vérifié par test (__tests__/use-cached-data.test.tsx,
réseau en échec avec et sans cache) et le parcours e2e
(e2e/scripts/capture-hors-connexion.sh) est prévu pour un build release.
Tests
La logique pure est couverte par Jest, sans dépendance native :
__tests__/apps-registry.test.ts— intégrité du registre,findApp,appsForRole, familles déclarées, ordre des sections (appsByFamily) ;__tests__/ent-catalog.test.ts— taxonomie partagée : libellés, familles (y compris celle déclarée par l'ENT, et le rejet d'une taxonomie obsolète), exclusions techniques ;__tests__/linking.test.ts— schémaopenent://, mappingapp/:appId, deep-links dérivés du registre ;__tests__/ui-kit.test.tsx— rendu des états d'écran, des onglets et du bandeau hors connexion (les premiers tests de rendu du projet) ;__tests__/cache.test.tset__tests__/use-cached-data.test.tsx— clé de cache portant l'instance, lecture/écriture tolérante aux données illisibles, et les quatre cas du repli (réseau seul, cache puis réseau, échec avec cache, échec sans cache) ;__tests__/push-routing.test.ts— résolution de l'application depuis des charges utiles réelles d'entcore (type,event-type,resourceUri), y compris pour les applications portées récemment, et absence d'ouverture quand la notification ne désigne rien ;__tests__/catalog.test.ts— jointurematchEnt, visibilité pilotée par l'ENT, repli statique, route ENT, parité de couverture (apps hors registre exposées, mêmes exclusions que le web, pas de doublon, connecteurs externes).
yarn workspace @openent/mobile test
Le test de fumée __tests__/App.test.tsx (rendu de <App/>) passe de nouveau :
jest.config.js transforme désormais les dépendances qui publient de l'ESM
(navigation, safe-area, WebView, client ENT) et jest.setup.js double les
modules natifs (stockage, biométrie, trousseau, Firebase, sélecteur d'images).
Il traverse toute la composition et attrape un export cassé ou un provider
manquant ; les modules purs restent couverts par leurs propres tests.
Étapes suivantes
- Déclarer le schéma
openent://et les liens universels côté natif (Androidintent-filter, iOSAssociated Domains). - Affiner les jetons
entMatchau vu desname/addressréels renvoyés par l'instance (cf. loguserinfo raw), et exploiterauthorizedActionspour les capacités fines (éditer vs consulter). - Porter en
nativeles applications découvertes les plus utilisées sur mobile (une entrée au registre + un écran, le reste de la chaîne ne bouge pas). - (Optionnel) Persister les récents et/ou conserver des instances WebView vivantes pour une bascule instantanée entre plusieurs apps.