Retour au blog
Transport16 min read

DLNA Cast : Construire un émetteur et un récepteur UPnP de zéro

Comment PlainApp implémente le protocole DLNA/UPnP AV des deux côtés : scanner et contrôler les téléviseurs via SOAP en tant qu'émetteur, et transformer le téléphone lui-même en MediaRenderer UPnP en tant que récepteur — avec la découverte SSDP, les métadonnées DIDL-Lite, le service de médias avec requêtes Range, les rappels d'événements GENA, et un modèle de confiance par liste autorisée/refusée basée sur l'IP de l'émetteur, le tout en pur Kotlin Multiplatform.

DLNA (construit sur UPnP AV) est le protocole derrière les boutons « Cast to TV » des téléviseurs connectés. Il fonctionne entièrement sur le réseau local, sans compte cloud ni étape d'appairage. PlainApp implémente les deux directions : il peut pousser une vidéo, une chanson ou une photo locale vers n'importe quel téléviseur compatible DLNA, et il peut transformer le téléphone lui-même en MediaRenderer pour qu'une application de télécommande TV, VLC ou un autre PlainApp puisse y diffuser à son tour.

Cet article couvre le protocole filaire (SSDP + SOAP + DIDL-Lite), le serveur HTTP local qui diffuse les médias avec les en-têtes que les téléviseurs exigent réellement, l'abonnement aux événements GENA pour l'état de la lecture, et le modèle de confiance par IP de l'émetteur qui permet à un protocole non authentifié datant des années 1990 de fonctionner en toute sécurité sur un téléphone moderne.

Table des matières

Architecture de haut niveau

Diagram 1
1

DLNA/UPnP AV n'a ni serveur central ni composant cloud — tout se passe sur le réseau local via UDP multicast (découverte) et HTTP (contrôle + médias). PlainApp implémente deux rôles indépendants qui partagent le même code de protocole dans commonMain :

RôleCe qu'il faitClasses clés
Émetteur (« Cast to TV »)Scanne les renderers, ordonne à l'un d'eux de récupérer une URL, contrôle la lectureDlnaDeviceScanner, DlnaTransportController, CastPlayer
Récepteur (« Wireless Cast »)S'annonce comme MediaRenderer, accepte le contrôle de tout contrôleur UPnPDlnaReceiverEngine, DlnaHttpRouter, DlnaSoapHandler, DlnaReceiverViewModel

Les deux rôles réutilisent DlnaSoap (constructeurs d'enveloppes SOAP) et DlnaDevice (le modèle d'appareil UPnP) depuis features/dlna/common/. La spécification DLNA n'intègre aucune authentification — n'importe qui sur le réseau local connaissant l'URL de contrôle d'un renderer peut lui envoyer des commandes. Cela façonne presque toutes les décisions de conception décrites ci-dessous, en particulier le modèle de confiance du récepteur.

Découverte SSDP : trouver des appareils sans serveur

La découverte utilise SSDP (Simple Service Discovery Protocol), une fine couche au-dessus du multicast UDP vers 239.255.255.250:1900. Il n'y a pas de serveur d'annuaire — les appareils s'annoncent eux-mêmes et répondent directement aux requêtes de recherche.

Diagram 2
2

En tant qu'émetteur, DlnaDeviceScanner diffuse un datagramme M-SEARCH ciblant urn:schemas-upnp-org:service:AVTransport:1 et collecte les réponses unicast 200 OK, chacune portant un en-tête LOCATION pointant vers le description.xml du renderer. Le scanner déduplique par hostAddress — il n'analyse pas lui-même le XML de l'appareil ; cette tâche est laissée à CastViewModel.searchAsync(), qui récupère LOCATION, appelle device.update(xml), et ne remonte que les appareils pour lesquels device.isAVTransport() retourne true :

fun search(): Flow<DlnaDevice> = searchDlnaDevicesRaw().transform { ssdp ->
    if (devices.none { it.hostAddress == ssdp.hostAddress }) {
        val device = DlnaDevice(ssdp.hostAddress, ssdp.header)
        devices.add(device)
        emit(device)
    }
}

En tant que récepteur, DlnaReceiverEngine.runSsdpLoop() envoie trois datagrammes NOTIFY ssdp:alive au démarrage (appareil racine, type d'appareil MediaRenderer:1, type de service AVTransport:1), se ré-annonce toutes les 30 secondes (CACHE-CONTROL: max-age=1800), et répond aux requêtes M-SEARCH entrantes avec des réponses unicast. Sur stop(), il envoie ssdp:byebye immédiatement plutôt que d'attendre l'expiration du cache de 30 minutes — ainsi, une application de télécommande TV cesse de lister PlainApp dès que « Wireless Cast » est désactivé.

Repli de port

Le serveur HTTP du récepteur essaie d'abord le port 7878, puis 7879, puis 7880, en privilégiant le dernier port qui a fonctionné :

private val CANDIDATE_PORTS = listOf(7878, 7879, 7880)
private fun openServerSocket(): DlnaServerSocket? {
    val candidates = lastPort
        ?.let { listOf(it) + CANDIDATE_PORTS.filter { p -> p != it } }
        ?: CANDIDATE_PORTS
    for (port in candidates) {
        val ss = createDlnaServerSocket(port)
        if (ss != null) return ss
    }
    return null
}

Si les trois ports sont occupés (rare, mais possible avec d'autres applications DLNA en cours d'exécution), startError est défini et remonté dans l'interface utilisateur plutôt que d'échouer silencieusement.

Contrôle AVTransport : le protocole SOAP

Une fois qu'un renderer est trouvé, la lecture est contrôlée via UPnP AVTransport, un service SOAP sur HTTP. Chaque action est un POST vers l'URL de contrôle du renderer avec un en-tête SOAPAction et un corps XML enveloppé dans une enveloppe SOAP.

Diagram 3
3

DlnaTransportController construit chaque requête avec une fonction helper partagée :

private suspend fun executeAVTransportCommand(
    device: DlnaDevice,
    action: String,
    parameters: String = "<InstanceID>0</InstanceID>",
): String {
    val st = device.getAVTransportService()?.serviceType ?: return ""
    return executeSOAPRequest(device, action, "<u:$action xmlns:u=\"$st\">$parameters</u:$action>")
}

executeSOAPRequest définit SOAPAction: "<serviceType>#<action>" et POSTe DlnaSoap.requestEnvelope(soapBody) — les mêmes constantes d'enveloppe utilisées côté récepteur pour construire les réponses, de sorte que le format filaire n'a besoin d'être défini qu'une seule fois dans commonMain.

Le DlnaHttpRouter.handleSoap() du récepteur fait miroir de l'autre côté : il lit l'en-tête soapaction, extrait le nom de l'action après le #, et dispatche en fonction — SetAVTransportURI, Play, Pause, Stop, Seek, GetTransportInfo, GetPositionInfo, GetMediaInfo, GetDeviceCapabilities. RenderingControl (volume) est stubé avec un 100 statique — PlainApp n'expose pas le volume de l'appareil via UPnP.

Métadonnées DIDL-Lite et l'étrangeté du double-échappement

SetAVTransportURI transporte deux paramètres : CurrentURI (l'URL du média) et CurrentURIMetaData — un fragment XML DIDL-Lite décrivant le titre, la classe de média et la pochette d'album, intégré sous forme de chaîne échappée XML à l'intérieur du corps SOAP externe :

private fun buildDidlLiteMetadata(mediaUrl: String, title: String, albumArtUri: String): String {
    val upnpClass = when {
        ext in setOf("mp3", "m4a", "flac", ...) -> "object.item.audioItem.musicTrack"
        ext in setOf("jpg", "jpeg", "png", ...) -> "object.item.imageItem"
        else -> "object.item.videoItem"
    }
    val didl = """<DIDL-Lite xmlns="..."><item id="0" parentID="-1" restricted="0">
        <dc:title>$escapedTitle</dc:title><upnp:class>$upnpClass</upnp:class>$albumArtTag</item></DIDL-Lite>"""
    return didl.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
}

Le XML DIDL-Lite est échappé deux fois : une fois pour le texte du titre lui-même (afin qu'une chanson nommée Fire & Ice ne casse pas les balises DIDL-Lite), et une fois pour l'ensemble du document DIDL-Lite (afin que ses propres </> ne cassent pas l'enveloppe SOAP externe dans laquelle il est intégré comme texte). C'est une particularité bien connue d'UPnP, pas un bug — CurrentURIMetaData est défini comme un contenu textuel, non comme des éléments XML imbriqués.

Côté récepteur, DlnaSoapHandler inverse ce processus : parseSoapAction dés-échappe le corps SOAP une fois pour obtenir le texte DIDL-Lite, puis extractTitleFromDidlMeta/extractMediaTypeFromDidlMeta/extractAlbumArtUriFromDidlMeta effectuent chacune un second passage de dés-échappement d'entités et d'extraction de balises sur cette chaîne interne :

fun extractMediaTypeFromDidlMeta(meta: String, fallbackUri: String = ""): DlnaMediaType {
    val cls = meta.substring(classStart + 12, classEnd).lowercase()
    return when {
        "audioitem" in cls || "musictrack" in cls -> DlnaMediaType.AUDIO
        "imageitem" in cls || "photo" in cls -> DlnaMediaType.IMAGE
        "videoitem" in cls -> DlnaMediaType.VIDEO
        else -> DlnaMediaType.UNKNOWN
    }
}

Si <upnp:class> est absent (certains émetteurs l'omettent), cleanMediaTitle() se rabat sur l'extension de fichier de l'URI elle-même — la détection du type de média n'échoue jamais complètement, elle se dégrade simplement en UNKNOWN qui est routé vers le lecteur vidéo comme valeur par défaut sûre.

Servir les médias au téléviseur : requêtes Range et en-têtes DLNA

Un appel SetAVTransportURI indique seulement au renderer où récupérer le média — les octets réels sont servis par le propre serveur HTTP local de PlainApp, à l'adresse /media/{id}.

Diagram 4
4

UrlHelper.getMediaHttpUrl(path) enregistre le chemin réel (qui peut être une URI content://, une URL distante, ou un chemin de fichier simple) sous un identifiant court et retourne http://<ip-appareil>:<port>/media/<id>.<ext>. La route bifurque ensuite selon le type de source :

when {
    path.isUrl() -> call.proxyUrl(path)                 // URL distante : diffuser la réponse amont
    isContentUri(path) -> call.respondStream { sink -> streamContentUri(path, sink) }
    path.isImageFast() -> call.respondFile(path)         // images : service statique simple
    else -> call.respondDlnaFile(path)                   // audio/vidéo : service adapté DLNA
}

respondDlnaFile est le cas intéressant — de nombreux téléviseurs connectés et renderers DLNA refusent de lire un flux à moins qu'il ne ressemble à une réponse de serveur média DLNA correcte :

override suspend fun respondDlnaFile(path: String): Boolean {
    val file = java.io.File(path)
    if (!file.exists()) return false
    applicationCall.response.run {
        header("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*")
        header("contentFeatures.dlna.org", "")
        header("transferMode.dlna.org", "Streaming")
        header("Connection", "keep-alive")
        header("Server", "DLNADOC/1.50 UPnP/1.0 Plain/1.0 Android/${android.os.Build.VERSION.RELEASE}")
        status(HttpStatusCode.PartialContent) // certains systèmes TV n'acceptent que le 206
    }
    applicationCall.respond(LocalFileContent(file))
    return true
}

Notez que le statut est toujours 206 Partial Content, pas 200 OK — certains firmware TV traitent une réponse 200 simple comme « non seekable » et refusent de la lire, même pour un GET de fichier complet. Ce seul choix de code de statut fait la différence entre « ça marche parfaitement » et « la TV tourne en rond indéfiniment » sur plusieurs appareils réels.

La même route sert également les pochettes d'album pour les éléments audio diffusés : UrlHelper.getAlbumArtHttpUrl() mappe une URI content://media/.../albumart/<id> vers le même chemin /media/{id}, de sorte que le streaming content:// et le service de fichiers DLNA partagent un même chemin de code, que le « média » en question soit la chanson ou son image de couverture.

Événements GENA : rappels d'état de lecture

Après le début de la lecture, l'émetteur s'abonne au service d'événements AVTransport du renderer (GENA — General Event Notification Architecture) afin d'être informé des changements d'état sans avoir à interroger :

suspend fun subscribeEvent(device: DlnaDevice, callbackUrl: String): String {
    val service = device.getAVTransportService() ?: return ""
    val response = createHttpClient().subscribe(baseUrl + eventSubURL) {
        headers { set("NT", "upnp:event"); set("TIMEOUT", "Second-3600"); set("CALLBACK", "<$callbackUrl>") }
    }
    return response.headers["SID"].orEmpty()
}

SUBSCRIBE/RENEW/UNSUBSCRIBE sont des méthodes HTTP personnalisées (ne faisant pas partie de l'ensemble de verbes standards), gérées via le constructeur de requêtes générique HttpMethod("SUBSCRIBE") de Ktor. Le renderer envoie ensuite un NOTIFY à callbackUrl — la propre route /callback/cast de PlainApp — à chaque changement d'état du transport, de position ou de durée :

if (xml.contains("TransportState val=\"STOPPED\"") && !xml.contains("AVTransportURIMetaData")) {
    // passer à l'élément suivant de la liste de lecture
} else if (xml.contains("TransportState val=\"PLAYING\"")) {
    CastPlayer.isPlaying.value = true
}

La protection contre les rappels en double

Certains renderers envoient deux rappels NOTIFY en succession rapide pour la même transition STOPPED — le second porte par hasard AVTransportURIMetaData alors que le premier non. Avancer la liste de lecture sur les deux ferait sauter une piste à chaque arrêt naturel de la lecture. Le test !xml.contains("AVTransportURIMetaData") est un filtre délibérément étroit : seule la première notification STOPPED (sans métadonnées) déclenche l'avancement automatique. Une tâche startPositionUpdater() interroge également GetPositionInfo toutes les secondes en guise de filet de sécurité, car l'accusé de réception SUBSCRIBE de PlainApp ne renvoie pas d'événements vers d'autres contrôleurs — seul l'émetteur consomme les rappels GENA en provenance des téléviseurs.

Mode récepteur : devenir un MediaRenderer UPnP

Inversons la direction : n'importe quel contrôleur DLNA (une application de télécommande TV, VLC, un autre PlainApp) peut pousser du média vers le téléphone lui-même. DlnaReceiverEngine ouvre le même type de serveur HTTP + SSDP décrit plus haut, mais cette fois en tant que MediaRenderer contrôlé plutôt que contrôleur.

DlnaHttpRouter.route() sert description.xml (construit par DlnaXmlTemplates.deviceDescription(), listant un service AVTransport et un service RenderingControl stubé) et distribue les actions SOAP à DlnaSoapHandler. Un appel SetAVTransportURI ne lance rien immédiatement — il stocke un PendingCastRequest et attend :

if (uri.isNotEmpty()) {
    DlnaRendererState.rawPendingCastRequest.value =
        PendingCastRequest(senderIp, senderName, uri, title, mediaType, albumArtUri)
    DlnaRendererState.pendingPlayQueued.value = false
}

Un Play qui arrive avant que la demande en attente ne soit résolue ne démarre pas non plus la lecture — il définit pendingPlayQueued = true pour que la commande soit rejouée automatiquement une fois la demande de cast acceptée, au lieu d'être silencieusement perdue :

val hasPending = DlnaRendererState.rawPendingCastRequest.value != null ||
    DlnaRendererState.pendingCastRequest.value != null
if (hasPending) {
    DlnaRendererState.pendingPlayQueued.value = true
} else {
    DlnaRendererState.commandChannel.trySend(DlnaCommand.Play)
}

Les commandes acceptées transitent par un seul Channel<DlnaCommand> que DlnaReceiverViewModel consomme, découplant la coroutine de gestion brute des sockets de l'état UI/player — le gestionnaire HTTP ne touche jamais ExoPlayer directement. La lecture est ensuite routée vers l'un des trois composables plein écran selon DlnaMediaType : DlnaReceiverAudioPlayerContent (fond dégradé, pochette d'album, barre de recherche), un visualiseur d'images, ou DlnaReceiverVideoPlayerContent (ExoPlayer).

Sécurité : confiance de l'émetteur via listes autorisées/refusées

DLNA n'a aucune authentification par conception — tout appareil sur le réseau local peut envoyer un SetAVTransportURI à un renderer. Transformer un téléphone personnel en MediaRenderer non authentifié permettrait à quiconque sur le même Wi-Fi (un réseau de bureau partagé, la maison d'un ami, un réseau invité hostile) de pousser des URL média arbitraires vers lui. PlainApp comble cette lacune avec une liste de confiance par IP d'émetteur, filtrant chaque demande de cast entrante :

Diagram 5
5

DlnaRendererState.rawPendingCastRequest.filterNotNull().collect { pending ->
    val allowed = DlnaAllowedSendersPreference.getAsync()
    val denied = DlnaDeniedSendersPreference.getAsync()
    when {
        DlnaAllowedSendersPreference.containsIp(allowed, pending.senderIp) -> {
            // Acceptation automatique : envoyer les commandes directement sans afficher de dialogue
        }
        DlnaDeniedSendersPreference.containsIp(denied, pending.senderIp) -> {
            // Rejet automatique : ignorer silencieusement
        }
        else -> {
            // Émetteur inconnu : promouvoir dans un état visible par l'UI pour que l'utilisateur décide
            DlnaRendererState.pendingCastRequest.value = pending
        }
    }
}

Une demande de cast provenant d'un émetteur inconnu affiche une boîte de dialogue de confirmation avec un indicateur optionnel « se souvenir de ce choix » ; choisir de se souvenir écrit l'IP de l'émetteur dans la préférence autorisée ou refusée, de sorte que les futures demandes de la même adresse ignorent la boîte de dialogue. Cette logique est entièrement appliquée dans DlnaReceiverViewModel, au-dessus du protocole filaire — le gestionnaire SOAP lui-même retourne toujours 200 OK quelle que soit la décision de confiance (conformément à la spécification UPnP, l'appel de transport a réussi ; le fait que le média soit réellement lu est une décision locale distincte).

Séparation des plates-formes : orchestration commonMain, sockets androidMain

Diagram 6
6

Suivant le même modèle que les autres fonctionnalités réseau de PlainApp, seule l'E/S de sockets au niveau brut des octets est spécifique à la plateforme. DlnaServerSocket et DlnaSsdpSocket sont des interfaces expect ; les implémentations actual d'Android encapsulent directement java.net.ServerSocket et java.net.MulticastSocket. Tout le reste — la sélection de port, la cadence SSDP alive/byebye, le routage HTTP, l'analyse SOAP, les métadonnées DIDL-Lite, et les quatre transitions d'état de DlnaCommand — réside dans commonMain et est testable unitairement sur la JVM sans appareil Android.

iOS bénéficie du contrôle SOAP côté émetteur (c'est un simple client HTTP, aucun socket à implémenter), mais le récepteur est délibérément une opération vide :

actual fun startDlnaRenderer() {}

iOS n'expose pas de moyen d'exécuter un écouteur multicast UDP en arrière-plan de manière suffisamment fiable pour être un bon citoyen MediaRenderer, donc « Wireless Cast » (réception) est réservé à Android ; « Cast to TV » (émission) fonctionne sur les deux.

Notes d'ingénierie pur Kotlin

Quelques détails d'implémentation existent spécifiquement pour éviter les dépendances de plateforme qui casseraient le partage Kotlin Multiplatform :

  • Génération UUID v4. DlnaReceiverEngine.randomUuid() fabrique manuellement un UUID v4 conforme à la RFC 4122 à partir de Random.nextBytes(16) au lieu de java.util.UUID.randomUUID(), de sorte que le générateur d'identité d'appareil soit identique sur toutes les plates-formes.
  • Décodage en pourcentage. Le percentDecode() privé de DlnaSoapHandler remplace java.net.URLDecoder.decode() pour les titres qui arrivent encodés en URL dans une URI média.
  • Lectures de corps HTTP au niveau octet. Content-Length est un compte d'octets, pas de caractères. AndroidDlnaClientConnection.readHttpRequest() lit le corps via readBodyBytes(bis, contentLength) sur le BufferedInputStream brut, jamais un BufferedReader/CharArray — un titre contenant de l'UTF-8 multi-octets (par exemple des caractères chinois, 3 octets chacun) lirait autrement insuffisamment le corps et bloquerait en attendant des octets déjà arrivés, cassant silencieusement SetAVTransportURI pour les titres non ASCII.
  • Analyse d'URL de base sans java.net.URL. DlnaDevice.getBaseUrl() extrait le schéma/hôte/port d'un en-tête LOCATION avec un simple découpage de chaîne (substringAfter("://"), substringBefore('/')) au lieu de construire un java.net.URL.

Récapitulatif des motifs de conception

MotifEmplacementRaison
Protocole partagé, E/S diviséesDlnaSoap/DlnaXmlTemplates dans commonMain, sockets dans androidMainFormat filaire défini une fois, la plateforme ne fait qu'entrer/sortir des octets
En attente → Vérification de règle → PromotionrawPendingCastRequest → pendingCastRequestSépare « une demande est arrivée » de « une demande nécessite une décision humaine »
Découplage par file de commandesChannel<DlnaCommand>La coroutine du gestionnaire HTTP ne touche jamais directement ExoPlayer/l'état UI
Rejeu de commande mise en filependingPlayQueuedUn Play qui devance l'approbation de SetAVTransportURI n'est pas perdu
Code de statut délibérérespondDlnaFile → toujours 206Correspond à ce que les firmware TV réels attendent, pas seulement le minimum spéci-fié 200
Filtre de duplication étroit!xml.contains("AVTransportURIMetaData")Distingue les deux rappels STOPPED que certains renderers envoient, sans mécanisme de déduplication générique
Byebye immédiatstop() envoie ssdp:byebye avant l'annulationÉvite une entrée de cache SSDP obsolète de 30 minutes après que l'utilisateur a désactivé la fonctionnalité
Ouvert par défaut sur inconnu, fermé par défautPréférence autorisée/refusée + dialogueNe fait ni automatiquement confiance ni ne bloque automatiquement un émetteur pour la première fois — un humain décide une fois

Lectures complémentaires

  • UPnP Device Architecture — la spécification sous-jacente SSDP/GENA/SOAP.
  • Pour la fonctionnalité de miroir d'écran basse latence basée sur WebSocket (un chemin de cast différent, non-DLNA), voir Screen Mirror.