Skip to main content

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 (RootNavigatorresolveExperience).

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 ForwardingCookieHandler dans le CookieManager de la WebView — la session ENT y est donc déjà présente ;
  • iOS : sharedCookiesEnabled expose 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.tsfetchEntApps() lit apps (name/address/icon/display/appType/category…) et authorizedActions (droits workflow). Best-effort : null en cas d'échec.
  • src/apps/entCatalog.tsportage 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ée explorer/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) sur entMatch vs name/address/prefix ENT, en forme normalisée (minuscules, sans accents ni séparateurs) ;
    • apps webview du registre : visibilité pilotée par l'ENT, routeaddress, comingSoon levé (l'app devient ouvrable) ;
    • apps native : visibilité par les roles statiques (parcours dédiés inconnus de l'ENT), route interne conservée ;
    • applications hors registre : un descripteur est fabriqué à la volée (entDescriptor → id ent:<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.
  • 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 de findMatchingPicto) ; à défaut la tuile affiche l'initiale du libellé.
  • src/apps/CatalogContext.tsx — charge le catalogue une fois après connexion et expose les hooks useCatalogSections (lanceur), useResolvedApps (AppSwitcher) et useResolvedApp (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 :

mountUsage
webviewBranchement 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.

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) : onNotificationOpenedAppopenApp(appId) ;
  • démarrage à froid : getInitialNotificationopenApp(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é :

ChampContenuExemple
typetype de notification, en majusculesBLOG, SCHOOLBOOK, RBS
event-typeévénement précisBLOG_POST_PUBLISH
paramsparamètres de la notification, sérialisés en JSON{"resourceUri":"/forum#/view/42"}
resource, sender, sub-resourceidentifiants 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ôles tab, 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.

Ce repli ne se teste pas en debug

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éma openent://, mapping app/:appId, deep-links dérivés du registre ;
  • __tests__/ui-kit.test.tsxrendu des états d'écran, des onglets et du bandeau hors connexion (les premiers tests de rendu du projet) ;
  • __tests__/cache.test.ts et __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 — jointure matchEnt, 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
note

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

  1. Déclarer le schéma openent:// et les liens universels côté natif (Android intent-filter, iOS Associated Domains).
  2. Affiner les jetons entMatch au vu des name/address réels renvoyés par l'instance (cf. log userinfo raw), et exploiter authorizedActions pour les capacités fines (éditer vs consulter).
  3. Porter en native les 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).
  4. (Optionnel) Persister les récents et/ou conserver des instances WebView vivantes pour une bascule instantanée entre plusieurs apps.