← Retour aux cas d'études
Architecture Mobile Hybride & Systèmes Embarqués

Conception et déploiement d'applications mobiles hybrides (iOS Swift & Android Kotlin) : Bridge WebViews, Deep Links et Push FCM

Les points clés de ce retour d'expérience :

  • Architecture hybride orientée conteneurs : Encapsulation du socle web responsive Symfony/Twig dans des wrappers natifs légers sous Kotlin (Android) et Swift (iOS).
  • Pont JavaScript natif bidirectionnel : Échanges directs entre le frontend TypeScript et les API natifs (JavascriptInterface et WKScriptMessageHandler) pour les permissions de notifications et le partage.
  • Routage Deep Links & Universal Links : Redirection automatique des notifications push et des URLs de partage vers les bonnes pages applicatives.
  • Gestion dynamique des permissions système : Demande dynamique des autorisations sous Android 13+ (POST_NOTIFICATIONS) et iOS avec rétro-action immédiate vers le frontend web via des événements personnalisés.
  • Gestion du cycle de vie FCM : Enregistrement des jetons APNs/FCM côté Symfony et invalidation immédiate à la déconnexion.

1. Exigences produit et contrainte d'ingénierie

Afin de toucher un plus grand nombre d'utilisateurs, j'ai conseillé le développement d'applications mobiles officielles sur Google Play et l'App Store, permettant notamment d'offrir des notifications push en temps réel lors des demandes de réservation et des échanges de messages.

Malgré ma préconisation initiale d'opter pour une application multiplateforme (ex: React Native) afin de mutualiser l'ensemble du code source, le choix d'ingénierie s'est porté sur une architecture hybride basée sur des WebViews et des ponts JS <-> natif, encapsulée dans deux conteneurs légers en Kotlin (Android) et Swift (iOS).

Même si cette approche implique toujours trois codebases à maintenir (Symfony, Android et iOS), elle évite la réécriture des formulaires complexes et garantit un alignement fonctionnel immédiat en réutilisant directement le socle Web :

  • Le cœur métier, les vues Twig et les formulaires s'exécutent directement dans la WebView WebKit/Chromium.
  • Les conteneurs natifs en Kotlin (Android) et Swift (iOS) communiquent via le pont JS pour les fonctionnalités système : enregistrement FCM, demande de permission pour Android 13+ (POST_NOTIFICATIONS), partage natif et routage des Universal Links.

Note importante et confidentialité : Pour respecter mes obligations de confidentialité et préserver la propriété intellectuelle, l'ensemble des extraits et exemples de code présentés ici a été entièrement recréé et anonymisé. De ce fait, des imprécisions ou de légères erreurs peuvent exister dans les exemples : le but de cet article est d'illustrer la démarche d'architecture et la réflexion technique, et non de fournir du code prêt à l'emploi.

2. Les frictions et verrous techniques d'une WebView basique

Intégrer une simple WebView sans couche d'adaptation intermédiaire se heurte rapidement à des verrous techniques majeurs qui dégradent l'expérience utilisateur et cassent les fonctionnalités clés :

  • Isolation et absence d'accès au système : Sans couche d'adaptation, la WebView fonctionne comme un onglet navigateur isolé. Elle ne peut ni invoquer le panneau de partage natif du téléphone, ni écouter le statut des permissions système.
  • Rupture avec les fonctionnalités système du mobile : Sans pont JS dédié, l'application web ne peut pas invoquer les API natives du système (partage natif Android/iOS, gestion fine des permissions comme POST_NOTIFICATIONS sous Android 13+). Les boîtes de dialogue web standards restent déconnectées de l'UX système.
  • Routage aveugle des notifications push et des liens : Lorsqu'une notification push est reçue ou qu'un lien externe est ouvert, l'absence de routage dynamique via Universal Links / Deep Links fait atterrir l'utilisateur sur la page d'accueil au lieu d'ouvrir directement le fil de discussion ou la demande de réservation ciblée.

C'est pour surmonter chacun de ces verrous sans alourdir les conteneurs mobiles que j'ai structuré l'application autour d'un pont JavaScript bidirectionnel et d'une intégration poussée des API natives.

3. Implémentation du Bridge Android en Kotlin

Côté Android, la communication repose sur deux composants distincts : la classe dédiée AppPlatformJsBridge.kt qui implémente les fonctionnalités exposées au Web, et le fragment principal FirstFragment.kt qui instancie la WebView et raccorde l'interface JavaScript.

1. La classe du pont dédié (AppPlatformJsBridge.kt) :

Cette classe contient l'ensemble des méthodes annotées par @JavascriptInterface. Chaque méthode publique ainsi annotée devient directement invocable par le frontend TypeScript :

package com.appplatform.mobileApp

import android.content.Context
import android.content.Intent
import android.webkit.JavascriptInterface

class AppPlatformJsBridge(private val context: Context) {

    @JavascriptInterface
    fun getAppVersion(): String = BuildConfig.VERSION_NAME

    @JavascriptInterface
    fun openNativeShare(title: String, url: String) {
        val sendIntent = Intent().apply {
            action = Intent.ACTION_SEND
            putExtra(Intent.EXTRA_TEXT, "$title $url")
            type = "text/plain"
        }
        context.startActivity(Intent.createChooser(sendIntent, "Partager via"))
    }
}

2. Raccordement et gestion des permissions dans FirstFragment.kt :

Lors de l'affichage de la vue, le fragment configure les paramètres de la WebView, injecte AppPlatformJsBridge sur l'objet global JS window.AndroidBridge et intercepte le chargement pour déclencher la demande de permission Android 13+ (POST_NOTIFICATIONS) :

package com.appplatform.mobileApp

import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.View
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment

class FirstFragment : Fragment() {

    private val notifPermissionLauncher = registerForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted: Boolean ->
        notifyJsPermissionState(granted)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        val webView = view.findViewById<WebView>(R.id.webview)

        webView.settings.javaScriptEnabled = true
        webView.settings.domStorageEnabled = true

        // Injection du pont JavaScript dédié sur window.AndroidBridge
        webView.addJavascriptInterface(AppPlatformJsBridge(requireContext()), "AndroidBridge")

        webView.webViewClient = object : WebViewClient() {
            override fun onPageFinished(view: WebView?, url: String?) {
                checkAndPromptNotificationPermission()
            }
        }
    }

    private fun checkAndPromptNotificationPermission() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            val isGranted = ContextCompat.checkSelfPermission(
                requireContext(),
                Manifest.permission.POST_NOTIFICATIONS
            ) == PackageManager.PERMISSION_GRANTED

            if (!isGranted) {
                notifPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
            }
        }
    }

    private fun notifyJsPermissionState(granted: Boolean) {
        val script = "window.dispatchEvent(new CustomEvent('onNativePermissionResult', { detail: { granted: $granted } }));"
        view?.findViewById<WebView>(R.id.webview)?.evaluateJavascript(script, null)
    }
}

Explication détaillée du fonctionnement et du flux de données :

  • Initialisation & Injection (FirstFragment.kt) : Dans onViewCreated, la méthode webView.addJavascriptInterface(AppPlatformJsBridge(requireContext()), "AndroidBridge") associe l'instance du pont à l'espace de nommage window.AndroidBridge. C'est cette ligne qui fait le lien entre le conteneur Kotlin et l'environnement WebKit/Chromium.
  • Canal Web vers Natif (AppPlatformJsBridge.kt) : Lorsqu'un utilisateur clique sur un bouton de partage dans l'application web, le frontend exécute window.AndroidBridge.openNativeShare(title, url). Kotlin intercepte cet appel et instancie un Intent.ACTION_SEND natif via Intent.createChooser() pour ouvrir la feuille de partage système Android.
  • Canal Natif vers Web (evaluateJavascript) : Inversement, lorsque le système Android renvoie une réponse asynchrone (ex: l'utilisateur accepte ou refuse la permission de notification), notifPermissionLauncher capte le résultat et invoque notifyJsPermissionState(). Celle-ci utilise evaluateJavascript() pour diffuser un CustomEvent('onNativePermissionResult') au sein de la WebView, permettant au frontend TypeScript d'adapter l'interface dynamiquement.
  • Redirection vers les paramètres Android (SettingsDeepLinkActivity) : Lorsque les autorisations de notification sont refusées par l'utilisateur au niveau du système OS, le bridge natif propose d'intercepter l'événement pour ouvrir directement la page de réglages de l'application via le schéma natif appplatform://settings/notifications, permettant à l'utilisateur de réactiver les notifications en un clic sans chercher dans les paramètres Android.

4. Implémentation du Bridge iOS en Swift

Côté iOS, l'architecture s'articule autour de deux briques natives : le gestionnaire de pont WebKit WKScriptMessageHandler qui écoute et traite les messages envoyés depuis le frontend JavaScript, et la classe AppDelegate.swift qui gère le cycle de vie des notifications push APNs/FCM et le routage des Universal Links.

1. Le gestionnaire de messages WebKit (WKScriptMessageHandler) :

Pour établir le pont natif iOS, on enregistre un gestionnaire de contenu sur la configuration WebKit (userContentController.add(..., name: "notify")). La classe implémente l'interface WKScriptMessageHandler et intercepte les messages JS :

import WebKit
import UserNotifications

class ScriptHandler: NSObject, WKScriptMessageHandler {
    weak var webView: WKWebView?

    func userContentController(
        _ userContentController: WKUserContentController,
        didReceive message: WKScriptMessage
    ) {
        guard message.name == "notify",
              let body = message.body as? [String: Any],
              let action = body["type"] as? String,
              action == "request"
        else { return }

        requestNotificationPermission { [weak self] granted in
            let js = "window.dispatchEvent(new CustomEvent('onNativePermissionResult', { detail: { granted: \(granted) } }));"
            self?.webView?.evaluateJavaScript(js, completionHandler: nil)
        }
    }

    private func requestNotificationPermission(completion: @escaping (Bool) -> Void) {
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
            DispatchQueue.main.async {
                completion(granted)
            }
        }
    }
}

2. Configuration du cycle de vie et des APNs dans AppDelegate.swift :

La classe AppDelegate enregistre l'application auprès d'Apple Push Notification service (APNs), transmet le jeton au SDK Firebase Messaging et intercepte les Universal Links pour le routage Web :

import UIKit
import FirebaseCore
import FirebaseMessaging
import UserNotifications

@main
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate, MessagingDelegate {

    var window: UIWindow?

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        FirebaseApp.configure()
        UNUserNotificationCenter.current().delegate = self
        Messaging.messaging().delegate = self

        application.registerForRemoteNotifications()
        return true
    }

    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        Messaging.messaging().apnsToken = deviceToken
    }

    func application(
        _ application: UIApplication,
        continue userActivity: NSUserActivity,
        restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
    ) -> Bool {
        if userActivity.activityType == NSUserActivityTypeBrowsingWeb,
           let url = userActivity.webpageURL {
            NotificationCenter.default.post(name: Notification.Name("UniversalLinkOpened"), object: url)
        }
        return true
    }

    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
    ) {
        completionHandler([.banner, .sound, .badge])
    }
}

Explication détaillée du fonctionnement du pont iOS :

  • Canal Web vers Natif (WKScriptMessageHandler) : Côté Web, JavaScript transmet un message au conteneur iOS via la syntaxe native WebKit window.webkit.messageHandlers.notify.postMessage({ type: 'request' }). Swift intercepte l'appel dans userContentController(_:didReceive:) et déclenche l'invite d'autorisation UNUserNotificationCenter.
  • Canal Natif vers Web (evaluateJavaScript) : Une fois le choix de l'utilisateur effectué dans le dialogue système iOS, la réponse est renvoyée au thread principal. Swift invoque evaluateJavaScript() sur l'instance WKWebView pour émettre l'événement onNativePermissionResult capté dynamiquement par le frontend TypeScript.
  • Liaison APNs / FCM & Universal Links (AppDelegate) : Lorsque iOS génère le jeton de périphérique natif (didRegisterForRemoteNotificationsWithDeviceToken), AppDelegate le transmet immédiatement à Firebase via Messaging.messaging().apnsToken = deviceToken. De plus, lors de l'ouverture d'un Universal Link (NSUserActivityTypeBrowsingWeb), l'URL est injectée dans le NotificationCenter pour effectuer la navigation au sein de la WebView sans perte d'état.

5. Intégration backend FCM & Universal Links sous Symfony

Côté serveur, l'intégration mobile requiert de couvrir l'ensemble du cycle de vie des notifications push et des liens profonds : la réception sécurisée des jetons envoyés par les conteneurs mobiles, l'expédition ciblée via FCM v1, et la certification du domaine pour autoriser les Universal Links / App Links.

5.1 Réception et persistance du jeton FCM (`RegisterNotificationDeviceApi.php`)

Dès que le conteneur mobile (Android ou iOS) obtient un jeton FCM valide, il expédie une requête POST vers l'API Symfony dédiée. Le contrôleur validera l'identité de l'utilisateur, nettoiera les anciens jetons et persistera le périphérique en base de données :

<?php

declare(strict_types=1);

namespace App\Infrastructure\Api\Front\Exposed\Misc;

use App\Domain\Entity\AppToken;
use App\Domain\Enum\AppTokenType;
use App\Domain\Repository\AppTokenRepositoryInterface;
use App\Domain\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class RegisterNotificationDeviceApi
{
    public const ANDROID_ROUTE_NAME = 'api_android_register';
    public const IOS_ROUTE_NAME = 'api_ios_register';

    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly UserRepositoryInterface $userRepository,
        private readonly AppTokenRepositoryInterface $appTokenRepository,
    ) {
    }

    #[Route(path: '/api/android-register', name: self::ANDROID_ROUTE_NAME, methods: ['POST'])]
    #[Route(path: '/api/ios-register', name: self::IOS_ROUTE_NAME, methods: ['POST'])]
    public function __invoke(Request $request): Response
    {
        $paramUserId = $request->request->get('userId');
        $paramUserSalt = $request->request->get('userToken');
        $paramAppToken = $request->request->get('appToken');
        $routeName = $request->attributes->get('_route');

        $tokenType = match ($routeName) {
            self::ANDROID_ROUTE_NAME => AppTokenType::ANDROID,
            self::IOS_ROUTE_NAME => AppTokenType::IOS,
            default => null,
        };

        if (!$tokenType || empty($paramAppToken) || empty($paramUserId)) {
            return new Response('Invalid request parameters', Response::HTTP_BAD_REQUEST);
        }

        $user = $this->userRepository->find($paramUserId);
        if (!$user || $user->getSalt() !== $paramUserSalt) {
            return new Response('User authentication failed', Response::HTTP_UNAUTHORIZED);
        }

        $existingToken = $this->appTokenRepository->findOneByToken($paramAppToken);
        if ($existingToken) {
            if ($existingToken->getUser() === $user) {
                return new Response('Token already registered for this user');
            }
            $this->em->remove($existingToken);
        }

        $appToken = new AppToken();
        $appToken->setType($tokenType);
        $appToken->setUser($user);
        $appToken->setValue($paramAppToken);

        $this->em->persist($appToken);
        $this->em->flush();

        return new Response('Token successfully registered');
    }
}

Invalidation et suppression du jeton à la déconnexion :

Pour éviter d'expédier des notifications push confidentielles à un appareil dont l'utilisateur s'est déconnecté, la procédure de déconnexion ou d'invalidation de session déclenche la tâche DeleteDeviceTask. Celle-ci supprime immédiatement l'entité AppToken associée dans Doctrine, garantissant qu'aucun jeton orphelin ne subsiste en base de données.

5.2 Expédition du Push via l'adapter FCM (`FirebaseNotification.php`)

Pour déclencher l'envoi d'une notification push lors d'un événement métier (ex: nouveau message ou réservation), l'adapter FirebaseNotification consomme l'entité AppToken et transmet la charge utile FCM v1 :

<?php

declare(strict_types=1);

namespace App\Infrastructure\Lib\DeviceNotification;

use App\Domain\Entity\Device;
use App\Domain\Service\DeviceNotificationInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class FirebaseNotification implements DeviceNotificationInterface
{
    public function __construct(
        private readonly HttpClientInterface $httpClient,
        private readonly string $firebaseProjectId,
    ) {
    }

    public function sendToDevice(Device $device, string $title, string $body, string $targetUrl): void
    {
        if (!$device->isNotificationActivated() || !$device->getPushToken()) {
            return;
        }

        $payload = [
            'message' => [
                'token' => $device->getPushToken(),
                'notification' => [
                    'title' => $title,
                    'body' => $body,
                ],
                'data' => [
                    'targetUrl' => $targetUrl,
                ],
            ],
        ];

        $this->httpClient->request(
            'POST',
            "https://fcm.googleapis.com/v1/projects/{$this->firebaseProjectId}/messages:send",
            [
                'json' => $payload,
            ]
        );
    }
}

Rôle du champ targetUrl dans l'expérience utilisateur :

Le champ personnalisé 'data' => ['targetUrl' => $targetUrl] est l'élément clé de la continuité d'expérience. Lorsque l'utilisateur clique sur la notification reçue sur son smartphone, le conteneur natif (iOS ou Android) extrait cette URL et la transmets au routeur de la WebView. L'utilisateur atterrit directement sur la page ciblée (ex: /booking/123) sans repasser par l'accueil.

5.3 Certification du domaine pour Universal Links & App Links (`.well-known`)

Pour qu'iOS et Android acceptent d'ouvrir les liens du domaine directement dans l'application mobile sans afficher de demande de confirmation dans le navigateur web, Symfony doit héberger les fichiers de configuration de signature à la racine du domaine (/.well-known/) :

  • iOS Universal Links (/.well-known/apple-app-site-association) : Fichier JSON (sans extension) qui liste l'ID de l'application Apple (AppID = TeamID.BundleID) et définit les motifs d'URL autorisés (ex: /booking/*, /messages/*).
  • Android App Links (/.well-known/assetlinks.json) : Fichier JSON certifiant l'empreinte numérique SHA-256 du certificat de signature de l'application Android et l'espace de nommage du package (com.appplatform.mobileApp).

6. Arbitrages techniques et enseignements

Dans mon installation, le choix d'un conteneur hybride sur mesure s'est avéré particulièrement payant :

  • Déploiement continu sans passage sur les stores : Toutes les évolutions fonctionnelles web (Twig/TypeScript) sont immédiatement disponibles dans les applications sans soumettre de nouveau binaire Apple/Google.
  • Intégration transparente des API natives : Le pont JS bidirectionnel permet d'accéder aux fonctionnalités système (partage natif, permissions) tout en conservant une expérience Web fluide.
  • Maîtrise du routage Universal Links : La certification du domaine via les fichiers .well-known garantit l'ouverture directe de l'application native sur la ressource ciblée sans passer par le navigateur web.