Volver al blog
Transport16 min read

DLNA Cast: Creando un Emisor y Receptor UPnP Desde Cero

Cómo PlainApp implementa la proyección DLNA/UPnP AV en ambos sentidos: escaneando y controlando televisores vía SOAP como emisor, y convirtiendo el propio teléfono en un UPnP MediaRenderer como receptor — con descubrimiento SSDP, metadatos DIDL-Lite, servicio de medios con soporte de Range Requests, callbacks de eventos GENA y un modelo de confianza basado en IP de origen permitida/denegada, todo en Kotlin Multiplatform puro.

DLNA (construido sobre UPnP AV) es el protocolo detrás de los botones "Cast to TV" en televisores inteligentes. Funciona completamente en la red local, sin necesidad de cuenta en la nube ni paso de emparejamiento. PlainApp implementa ambas direcciones: puede enviar un video, canción o foto local a cualquier televisor inteligente compatible con DLNA, y también puede convertir el propio teléfono en un MediaRenderer para que una app de control remoto de TV, VLC u otro PlainApp pueda proyectar hacia él.

Este artículo cubre el protocolo de red (SSDP + SOAP + DIDL-Lite), el servidor HTTP local que transmite medios con las cabeceras que los televisores realmente requieren, la suscripción a eventos GENA para el estado de reproducción, y el modelo de confianza por IP de origen que mantiene seguro un protocolo sin autenticación de la era de los 90 ejecutándose en un teléfono moderno.

Tabla de Contenidos

Arquitectura de Alto Nivel

Diagram 1
1

DLNA/UPnP AV no tiene servidor central ni componente en la nube — todo ocurre sobre multicast UDP en la red local (descubrimiento) y HTTP (control + medios). PlainApp implementa dos roles independientes que comparten el mismo código de protocolo en commonMain:

RolQué haceClases clave
Emisor ("Cast to TV")Escanea renderizadores, le indica a uno que obtenga una URL, controla la reproducciónDlnaDeviceScanner, DlnaTransportController, CastPlayer
Receptor ("Wireless Cast")Se anuncia a sí mismo como un MediaRenderer, acepta control de cualquier controlador UPnPDlnaReceiverEngine, DlnaHttpRouter, DlnaSoapHandler, DlnaReceiverViewModel

Ambos roles reutilizan DlnaSoap (constructores de sobres SOAP) y DlnaDevice (el modelo de dispositivo UPnP) desde features/dlna/common/. La especificación DLNA no tiene autenticación de ningún tipo — cualquiera en la LAN que conozca la URL de control de un renderizador puede enviarle comandos. Esto condiciona casi todas las decisiones de diseño descritas a continuación, especialmente el modelo de confianza del receptor.

Descubrimiento SSDP: Encontrar Dispositivos Sin un Servidor

El descubrimiento utiliza SSDP (Simple Service Discovery Protocol), una capa ligera sobre UDP multicast hacia 239.255.255.250:1900. No hay un servidor de directorio — los dispositivos se anuncian a sí mismos y responden consultas de búsqueda directamente.

Diagram 2
2

Como emisor, DlnaDeviceScanner difunde un datagrama M-SEARCH dirigido a urn:schemas-upnp-org:service:AVTransport:1 y recolecta respuestas unicast 200 OK, cada una con una cabecera LOCATION que apunta al description.xml del renderizador. El escáner desduplica por hostAddress — no analiza el XML del dispositivo por sí mismo; eso lo hace CastViewModel.searchAsync(), que obtiene LOCATION, llama a device.update(xml), y solo muestra los dispositivos donde device.isAVTransport() devuelve 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)
    }
}

Como receptor, DlnaReceiverEngine.runSsdpLoop() envía tres datagramas NOTIFY ssdp:alive al iniciar (dispositivo raíz, tipo de dispositivo MediaRenderer:1, tipo de servicio AVTransport:1), re-anuncia cada 30 segundos (CACHE-CONTROL: max-age=1800), y responde a las solicitudes M-SEARCH entrantes con respuestas unicast. En stop(), envía ssdp:byebye inmediatamente en lugar de esperar los 30 minutos de caché — así una app de control remoto de TV deja de listar PlainApp en cuanto se desactiva "Wireless Cast".

Puerto de respaldo

El servidor HTTP del receptor prueba primero el puerto 7878, luego 7879, luego 7880, prefiriendo el puerto que funcionó la última vez:

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 los tres puertos están ocupados (raro, pero posible con otras apps DLNA ejecutándose), se establece startError y se muestra en la interfaz en lugar de fallar silenciosamente.

Control AVTransport: El Protocolo SOAP

Una vez encontrado un renderizador, la reproducción se controla a través de UPnP AVTransport, un servicio SOAP sobre HTTP. Cada acción es un POST a la URL de control del renderizador con una cabecera SOAPAction y un cuerpo XML envuelto en un sobre SOAP.

Diagram 3
3

DlnaTransportController construye cada solicitud con un helper compartido:

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 establece SOAPAction: "<serviceType>#<action>" y envía DlnaSoap.requestEnvelope(soapBody) — las mismas constantes de sobre que usa el lado receptor para construir respuestas, de modo que el formato de red solo necesita definirse una vez en commonMain.

El DlnaHttpRouter.handleSoap() del receptor refleja esto en el otro extremo: lee la cabecera soapaction, extrae el nombre de la acción después del #, y despacha según corresponda —SetAVTransportURI, Play, Pause, Stop, Seek, GetTransportInfo, GetPositionInfo, GetMediaInfo, GetDeviceCapabilities. RenderingControl (volumen) está dummy con un 100 estático — PlainApp no expone el volumen del dispositivo a través de UPnP.

Metadatos DIDL-Lite y la Peculiaridad del Doble Escape

SetAVTransportURI lleva dos parámetros: CurrentURI (la URL del medio) y CurrentURIMetaData — un fragmento XML DIDL-Lite que describe el título, la clase de medio y el álbum arte, incrustado como una cadena con escape XML dentro del cuerpo SOAP exterior:

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;")
}

El XML DIDL-Lite se escapa dos veces: una para el propio texto del título (para que una canción llamada Fire & Ice no rompa las etiquetas DIDL-Lite), y otra para todo el documento DIDL-Lite (para que sus propios </> no rompan el sobre SOAP exterior en el que está incrustado como texto). Esta es una peculiaridad conocida de UPnP, no un error — CurrentURIMetaData está definido como contenido de cadena, no como elementos XML anidados.

En el lado receptor, DlnaSoapHandler invierte esto: parseSoapAction desescapa el cuerpo SOAP una vez para obtener el texto DIDL-Lite, luego extractTitleFromDidlMeta/extractMediaTypeFromDidlMeta/ extractAlbumArtUriFromDidlMeta realizan cada una un segundo pase de desescape de entidades y extracción de etiquetas sobre esa cadena interna:

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 falta <upnp:class> (algunos emisores lo omiten), cleanMediaTitle() recurre a la extensión de archivo del propio URI — la detección de tipo de medio nunca falla de forma irreversible, simplemente degrada a UNKNOWN que se dirige al reproductor de video como valor seguro por defecto.

Sirviendo Medios al TV: Range Requests y Cabeceras DLNA

Una llamada SetAVTransportURI solo le indica al renderizador dónde obtener el medio — los bytes reales los sirve el propio servidor HTTP local de PlainApp, en /media/{id}.

Diagram 4
4

UrlHelper.getMediaHttpUrl(path) registra la ruta real (que puede ser un URI content://, una URL remota o una ruta de archivo simple) bajo un id corto y devuelve http://<ip-dispositivo>:<puerto>/media/<id>.<ext>. La ruta luego se bifurca según el tipo de origen:

when {
    path.isUrl() -> call.proxyUrl(path)                 // URL remota: transmitir respuesta upstream
    isContentUri(path) -> call.respondStream { sink -> streamContentUri(path, sink) }
    path.isImageFast() -> call.respondFile(path)         // imágenes: servicio estático simple
    else -> call.respondDlnaFile(path)                   // audio/video: servicio consciente de DLNA
}

respondDlnaFile es el caso interesante — muchos televisores inteligentes y renderizadores DLNA se niegan a reproducir un flujo a menos que parezca una respuesta adecuada de un servidor de medios DLNA:

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) // algunos sistemas operativos de TV solo aceptan 206
    }
    applicationCall.respond(LocalFileContent(file))
    return true
}

Nótese que el estado es siempre 206 Partial Content, no 200 OK — algunos firmwares de TV tratan una respuesta 200 simple como "no seekeable" y se niegan a reproducirla, incluso para un GET de archivo completo. Esta única elección de código de estado marca la diferencia entre "se reproduce correctamente" y "el TV muestra un spinner eterno" en varios dispositivos reales.

La misma ruta también sirve álbum arte para elementos de audio proyectados: UrlHelper.getAlbumArtHttpUrl() mapea un URI content://media/.../albumart/<id> a la misma ruta /media/{id}, de modo que la transmisión content:// y el servicio de archivos DLNA comparten una única ruta de código, ya sea que el "medio" en cuestión sea la canción o su imagen de portada.

Eventos GENA: Callbacks de Estado de Reproducción

Después de iniciar la reproducción, el emisor se suscribe al servicio de eventos AVTransport del renderizador (GENA — General Event Notification Architecture) para conocer los cambios de estado sin necesidad de polling:

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 son métodos HTTP personalizados (no están en el conjunto de verbos estándar), manejados a través del constructor de solicitudes genérico HttpMethod("SUBSCRIBE") de Ktor. El renderizador entonces hace NOTIFY a callbackUrl — la ruta /callback/cast de PlainApp — cada vez que cambia el estado de transporte, la posición o la duración:

if (xml.contains("TransportState val=\"STOPPED\"") && !xml.contains("AVTransportURIMetaData")) {
    // avanzar al siguiente elemento de la lista de reproducción
} else if (xml.contains("TransportState val=\"PLAYING\"")) {
    CastPlayer.isPlaying.value = true
}

El guardia contra duplicados de callback

Algunos renderizadores envían dos callbacks NOTIFY en rápida sucesión para la misma transición STOPPED — el segundo lleva AVTransportURIMetaData mientras que el primero no. Avanzar la lista de reproducción en ambos saltaría una pista cada vez que la reproducción se detiene naturalmente. La comprobación !xml.contains("AVTransportURIMetaData") es un filtro deliberado y estrecho: solo la primera notificación STOPPED (sin metadatos) activa el avance automático. Un job startPositionUpdater() también consulta GetPositionInfo cada segundo como mecanismo de respaldo, ya que el propio acuse de recibo de SUBSCRIBE de PlainApp no envía eventos de vuelta a otros controladores — solo el lado emisor consume callbacks GENA de los televisores.

Modo Receptor: Convertirse en un UPnP MediaRenderer

Invierta la dirección: cualquier controlador DLNA (una app de control remoto de TV, VLC, otro PlainApp) puede enviar medios al propio teléfono. DlnaReceiverEngine abre el mismo tipo de servidor HTTP + SSDP descrito anteriormente, pero como el MediaRenderer que está siendo controlado, no como el controlador.

DlnaHttpRouter.route() sirve description.xml (construido por DlnaXmlTemplates.deviceDescription(), que lista un servicio AVTransport y un RenderingControl dummy) y despacha acciones SOAP a DlnaSoapHandler. Una llamada SetAVTransportURI no reproduce nada inmediatamente — almacena un PendingCastRequest y espera:

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

Un Play que llega antes de que se resuelva la solicitud pendiente tampoco inicia la reproducción — establece pendingPlayQueued = true para que el comando se reproduzca automáticamente una vez que se acepte la solicitud de proyección, en lugar de perderse silenciosamente:

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

Los comandos aceptados fluyen a través de un único Channel<DlnaCommand> que DlnaReceiverViewModel consume, desacoplando la corrutina de manejo de sockets del estado de UI/reproductor — el manejador HTTP nunca toca ExoPlayer directamente. La reproducción luego se dirige a uno de tres componibles de pantalla completa según DlnaMediaType: DlnaReceiverAudioPlayerContent (fondo degradado, álbum arte, barra de búsqueda), un visor de imágenes, o DlnaReceiverVideoPlayerContent (ExoPlayer).

Seguridad: Confianza de Emisor vía Listas de Permitidos/Denegados

DLNA no tiene autenticación por diseño — cualquier dispositivo en la LAN puede enviar un SetAVTransportURI a un renderizador. Convertir un teléfono personal en un MediaRenderer sin autenticación permitiría que cualquiera en el mismo Wi-Fi (una red de oficina compartida, la casa de un amigo, una red de invitados hostil) envíe URLs de medios arbitrarias. PlainApp cierra esta brecha con una lista de confianza por IP de emisor, filtrando cada solicitud de proyección entrante:

Diagram 5
5

DlnaRendererState.rawPendingCastRequest.filterNotNull().collect { pending ->
    val allowed = DlnaAllowedSendersPreference.getAsync()
    val denied = DlnaDeniedSendersPreference.getAsync()
    when {
        DlnaAllowedSendersPreference.containsIp(allowed, pending.senderIp) -> {
            // Auto-aceptar: enviar comandos directamente sin mostrar diálogo
        }
        DlnaDeniedSendersPreference.containsIp(denied, pending.senderIp) -> {
            // Auto-rechazar: descartar silenciosamente
        }
        else -> {
            // Emisor desconocido: promover a estado visible en UI para que el usuario decida
            DlnaRendererState.pendingCastRequest.value = pending
        }
    }
}

La solicitud de proyección de un emisor desconocido muestra un diálogo de confirmación con una bandera opcional "recordar esta decisión"; elegir recordar escribe la IP del emisor en la preferencia de permitidos o denegados para que futuras solicitudes de la misma dirección omitan el diálogo. Esto se aplica completamente en DlnaReceiverViewModel, por encima del protocolo de red — el manejador SOAP siempre devuelve 200 OK independientemente de la decisión de confianza (según la especificación UPnP, la llamada de transporte fue exitosa; si el medio realmente se reproduce es una decisión local separada).

División de Plataforma: commonMain para Orquestación, androidMain para Sockets

Diagram 6
6

Siguiendo el mismo patrón que otras funciones de red de PlainApp, solo la E/S de sockets a nivel de bytes es específica de la plataforma. DlnaServerSocket y DlnaSsdpSocket son interfaces expect; las implementaciones actual de Android envuelven java.net.ServerSocket y java.net.MulticastSocket directamente. Todo lo demás — selección de puerto, cadencia de alive/byebye SSDP, enrutamiento HTTP, análisis SOAP, metadatos DIDL-Lite y las cuatro transiciones de estado de DlnaCommand — vive en commonMain y se puede probar unitariamente en la JVM sin un dispositivo Android.

iOS tiene control SOAP del lado emisor (es un cliente HTTP simple, no hay sockets que implementar), pero el receptor es un no-op deliberado:

actual fun startDlnaRenderer() {}

iOS no expone una forma de ejecutar un listener de multicast UDP en segundo plano de manera suficientemente confiable como para ser un buen ciudadano MediaRenderer, por lo que "Wireless Cast" (recepción) es solo para Android; "Cast to TV" (envío) funciona en ambas plataformas.

Notas de Ingeniería en Kotlin Puro

Algunos detalles de implementación existen específicamente para evitar dependencias de plataforma que romperían el uso compartido de Kotlin Multiplatform:

  • Generación de UUID v4. DlnaReceiverEngine.randomUuid() implementa manualmente un UUID v4 según RFC 4122 a partir de Random.nextBytes(16) en lugar de java.util.UUID.randomUUID(), para que el generador de identidad del dispositivo sea idéntico en todas las plataformas.
  • Percent-decoding. El percentDecode() privado de DlnaSoapHandler reemplaza a java.net.URLDecoder.decode() para títulos que llegan codificados en URL dentro de un URI de medio.
  • Lectura del cuerpo HTTP a nivel de bytes. Content-Length es un conteo de bytes, no de caracteres. AndroidDlnaClientConnection.readHttpRequest() lee el cuerpo a través de readBodyBytes(bis, contentLength) contra el BufferedInputStream raw, nunca un BufferedReader/CharArray — un título que contenga UTF-8 multibyte (por ejemplo, caracteres chinos, 3 bytes cada uno) de lo contrario leería menos bytes de los necesarios y se quedaría esperando bytes que ya llegaron, rompiendo silenciosamente SetAVTransportURI para títulos no ASCII.
  • Análisis de URL base sin java.net.URL.DlnaDevice.getBaseUrl() extrae el esquema/host/puerto de una cabecera LOCATION con operaciones de cadena simples (substringAfter("://"), substringBefore('/')) en lugar de construir un java.net.URL.

Resumen de Patrones de Diseño

PatrónDóndePor qué
Protocolo Compartido, E/S DivididaDlnaSoap/DlnaXmlTemplates en commonMain, sockets en androidMainFormato de red definido una vez, la plataforma solo suministra bytes de entrada/salida
Pendiente → Verificación de Regla → PromoverrawPendingCastRequest → pendingCastRequestSepara "llegó una solicitud" de "la solicitud necesita una decisión humana"
Desacoplamiento con Cola de ComandosChannel<DlnaCommand>La corrutina del manejador HTTP nunca toca ExoPlayer/estado de UI directamente
Reproducción de Comando en ColapendingPlayQueuedUn Play que llega antes de la aprobación de SetAVTransportURI no se pierde
Código de Estado DeliberadorespondDlnaFile → siempre 206Coincide con lo que el firmware real de TV espera, no solo el mínimo de la especificación 200
Filtro Estrecho contra Duplicados!xml.contains("AVTransportURIMetaData")Distingue los dos callbacks STOPPED que algunos renderizadores envían, sin un mecanismo de desduplicación genérico
Byebye Inmediatostop() envía ssdp:byebye antes de cancelarEvita una entrada de caché SSDP obsoleta de 30 minutos después de que el usuario desactiva la función
Abrir ante Desconocido, Cerrado por Defectopreferencia de permitidos/denegados + diálogoNi auto-confía ni auto-bloquea a un emisor por primera vez — un humano decide una vez

Lecturas Adicionales

  • UPnP Device Architecture — la especificación subyacente de SSDP/GENA/SOAP.
  • Para la función de espejo de pantalla de baja latencia basada en WebSocket (una ruta de proyección diferente, no DLNA), consulta Screen Mirror.