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
- Tabla de Contenidos
- Arquitectura de Alto Nivel
- Descubrimiento SSDP: Encontrar Dispositivos Sin un Servidor
- Control AVTransport: El Protocolo SOAP
- Metadatos DIDL-Lite y la Peculiaridad del Doble Escape
- Sirviendo Medios al TV: Range Requests y Cabeceras DLNA
- Eventos GENA: Callbacks de Estado de Reproducción
- Modo Receptor: Convertirse en un UPnP MediaRenderer
- Seguridad: Confianza de Emisor vía Listas de Permitidos/Denegados
- División de Plataforma: commonMain para Orquestación, androidMain para Sockets
- Notas de Ingeniería en Kotlin Puro
- Resumen de Patrones de Diseño
- Lecturas Adicionales
Arquitectura de Alto Nivel
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:
| Rol | Qué hace | Clases clave |
|---|---|---|
| Emisor ("Cast to TV") | Escanea renderizadores, le indica a uno que obtenga una URL, controla la reproducción | DlnaDeviceScanner, DlnaTransportController, CastPlayer |
| Receptor ("Wireless Cast") | Se anuncia a sí mismo como un MediaRenderer, acepta control de cualquier controlador UPnP | DlnaReceiverEngine, 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.
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.
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("&", "&").replace("<", "<").replace(">", ">")
}
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}.
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:
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
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 deRandom.nextBytes(16)en lugar dejava.util.UUID.randomUUID(), para que el generador de identidad del dispositivo sea idéntico en todas las plataformas. - Percent-decoding. El
percentDecode()privado deDlnaSoapHandlerreemplaza ajava.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-Lengthes un conteo de bytes, no de caracteres.AndroidDlnaClientConnection.readHttpRequest()lee el cuerpo a través dereadBodyBytes(bis, contentLength)contra elBufferedInputStreamraw, nunca unBufferedReader/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 silenciosamenteSetAVTransportURIpara títulos no ASCII. - Análisis de URL base sin
java.net.URL.DlnaDevice.getBaseUrl()extrae el esquema/host/puerto de una cabeceraLOCATIONcon operaciones de cadena simples (substringAfter("://"),substringBefore('/')) en lugar de construir unjava.net.URL.
Resumen de Patrones de Diseño
| Patrón | Dónde | Por qué |
|---|---|---|
| Protocolo Compartido, E/S Dividida | DlnaSoap/DlnaXmlTemplates en commonMain, sockets en androidMain | Formato de red definido una vez, la plataforma solo suministra bytes de entrada/salida |
| Pendiente → Verificación de Regla → Promover | rawPendingCastRequest → pendingCastRequest | Separa "llegó una solicitud" de "la solicitud necesita una decisión humana" |
| Desacoplamiento con Cola de Comandos | Channel<DlnaCommand> | La corrutina del manejador HTTP nunca toca ExoPlayer/estado de UI directamente |
| Reproducción de Comando en Cola | pendingPlayQueued | Un Play que llega antes de la aprobación de SetAVTransportURI no se pierde |
| Código de Estado Deliberado | respondDlnaFile → siempre 206 | Coincide 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 Inmediato | stop() envía ssdp:byebye antes de cancelar | Evita una entrada de caché SSDP obsoleta de 30 minutos después de que el usuario desactiva la función |
| Abrir ante Desconocido, Cerrado por Defecto | preferencia de permitidos/denegados + diálogo | Ni 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.