DLNA (construído sobre UPnP AV) é o protocolo por trás dos botões "Transmitir para TV" em
smart TVs. Ele funciona inteiramente na rede local, sem conta na nuvem
e sem etapa de pareamento. O PlainApp implementa ambas as direções: ele
pode enviar um vídeo, música ou foto local para qualquer smart TV compatível com DLNA,
e pode transformar o próprio telefone em um MediaRenderer para que um aplicativo de controle
remoto de TV, VLC ou outro PlainApp transmita para ele.
Este artigo aborda o protocolo de rede (SSDP + SOAP + DIDL-Lite), o servidor HTTP local que transmite mídia com os cabeçalhos que as TVs realmente exigem, a assinatura de eventos GENA para estado de reprodução e o modelo de confiança por IP do remetente que mantém um protocolo não autenticado da década de 1990 seguro para execução em um telefone moderno.
Índice
- Índice
- Arquitetura de Alto Nível
- Descoberta SSDP: Encontrando Dispositivos Sem um Servidor
- Controle AVTransport: O Protocolo SOAP
- Metadados DIDL-Lite e a Peculiaridade do Duplo Escape
- Servindo Mídia para a TV: Range Requests e Cabeçalhos DLNA
- Eventos GENA: Callbacks de Estado de Reprodução
- Modo Receptor: Tornando-se um MediaRenderer UPnP
- Segurança: Confiança do Remetente via Listas de Permissão/Negação
- Divisão de Plataforma: Orquestração em commonMain, Sockets em androidMain
- Notas de Engenharia em Kotlin Puro
- Resumo dos Padrões de Projeto
- Leitura Adicional
Arquitetura de Alto Nível
DLNA/UPnP AV não tem servidor central nem componente de nuvem — tudo
acontece via multicast UDP na rede local (descoberta) e HTTP (controle +
mídia). O PlainApp implementa dois papéis independentes que compartilham o
mesmo código de protocolo em commonMain:
| Papel | O que faz | Classes principais |
|---|---|---|
| Remetente ("Transmitir para TV") | Escaneia por renderizadores, instrui um a buscar uma URL, controla a reprodução | DlnaDeviceScanner, DlnaTransportController, CastPlayer |
| Receptor ("Transmissão Sem Fio") | Anuncia-se como um MediaRenderer, aceita controle de qualquer controlador UPnP | DlnaReceiverEngine, DlnaHttpRouter, DlnaSoapHandler, DlnaReceiverViewModel |
Ambos os papéis reutilizam DlnaSoap (construtores de envelope SOAP) e DlnaDevice (o
modelo de dispositivo UPnP) de features/dlna/common/. A especificação DLNA
não tem autenticação de nenhum tipo — qualquer um na LAN que saiba a
URL de controle de um renderizador pode enviar comandos a ele. Isso molda quase todas
as decisões de projeto descritas abaixo, especialmente o modelo de confiança do receptor.
Descoberta SSDP: Encontrando Dispositivos Sem um Servidor
A descoberta usa SSDP (Simple Service Discovery Protocol), uma camada fina
sobre multicast UDP em 239.255.255.250:1900. Não há servidor de
diretório — os dispositivos se anunciam e respondem a consultas de pesquisa diretamente.
Como remetente, DlnaDeviceScanner transmite um datagrama M-SEARCH
visando urn:schemas-upnp-org:service:AVTransport:1 e coleta
respostas unicast 200 OK, cada uma carregando um cabeçalho LOCATION apontando para
o description.xml do renderizador. O scanner deduplica por
hostAddress — ele não analisa o XML do dispositivo em si; isso fica a cargo de
CastViewModel.searchAsync(), que busca LOCATION, chama
device.update(xml), e só exibe dispositivos onde
device.isAVTransport() retorna 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() envia três
datagramas NOTIFY ssdp:alive na inicialização (dispositivo raiz, tipo de dispositivo
MediaRenderer:1, tipo de serviço AVTransport:1), reanuncia a cada 30 segundos
(CACHE-CONTROL: max-age=1800), e responde a requisições M-SEARCH recebidas
com respostas unicast. Em stop(), ele envia ssdp:byebye imediatamente
em vez de esperar o cache de 30 minutos expirar — assim um aplicativo de controle remoto
de TV para de listar o PlainApp no momento em que a "Transmissão Sem Fio" é desligada.
Fallback de porta
O servidor HTTP do receptor tenta a porta 7878 primeiro, depois 7879, depois
7880, preferindo a porta que funcionou da ú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
}
Se todas as três portas estiverem ocupadas (raro, mas possível com outros aplicativos DLNA
em execução), startError é definido e exibido na interface em vez de falhar
silenciosamente.
Controle AVTransport: O Protocolo SOAP
Uma vez que um renderizador é encontrado, a reprodução é controlada através do serviço
UPnP AVTransport, um serviço SOAP sobre HTTP. Cada ação é um POST para
a URL de controle do renderizador com um cabeçalho SOAPAction e um corpo XML envolvido
em um envelope SOAP.
DlnaTransportController constrói cada requisição com um helper compartilhado:
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 define SOAPAction: "<serviceType>#<action>" e envia
DlnaSoap.requestEnvelope(soapBody) — as mesmas constantes de envelope usadas
pelo lado receptor para construir respostas, de modo que o formato de rede só precisa ser
definido uma vez em commonMain.
O DlnaHttpRouter.handleSoap() do receptor espelha isso na outra
ponta: ele lê o cabeçalho soapaction, extrai o nome da ação após o
#, e despacha com base nela — SetAVTransportURI, Play, Pause, Stop,
Seek, GetTransportInfo, GetPositionInfo, GetMediaInfo,
GetDeviceCapabilities. RenderingControl (volume) é implementado como stub com um
valor fixo de 100 — o PlainApp não expõe o volume do dispositivo através do UPnP.
Metadados DIDL-Lite e a Peculiaridade do Duplo Escape
SetAVTransportURI carrega dois parâmetros: CurrentURI (a URL da mídia)
e CurrentURIMetaData — um fragmento XML DIDL-Lite descrevendo o
título, a classe de mídia e a capa do álbum, incorporado como uma string com escape XML
dentro do corpo SOAP externo:
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(">", ">")
}
O XML DIDL-Lite é escapado duas vezes: uma para o texto do título em si
(para que uma música chamada Fire & Ice não quebre as tags DIDL-Lite), e outra
para o documento DIDL-Lite inteiro (para que seus próprios </> não quebrem o
envelope SOAP externo no qual está incorporado como texto). Esta é uma peculiaridade
bem conhecida do UPnP, não um bug — CurrentURIMetaData é definido como conteúdo de string,
não como elementos XML aninhados.
No lado receptor, DlnaSoapHandler reverte isso: parseSoapAction
remove o escape do corpo SOAP uma vez para obter o texto DIDL-Lite, então
extractTitleFromDidlMeta/extractMediaTypeFromDidlMeta/
extractAlbumArtUriFromDidlMeta cada uma faz uma segunda passagem de
remoção de escape de entidades e extração de tags nessa string 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
}
}
Se <upnp:class> estiver ausente (alguns remetentes o omitem), cleanMediaTitle()
recorre à extensão de arquivo da própria URI — a detecção
de tipo de mídia nunca falha completamente, apenas degrada para UNKNOWN, que é direcionado
para o player de vídeo como um padrão seguro.
Servindo Mídia para a TV: Range Requests e Cabeçalhos DLNA
Uma chamada SetAVTransportURI apenas diz ao renderizador onde buscar a
mídia — os bytes reais são servidos pelo próprio servidor HTTP local do PlainApp,
em /media/{id}.
UrlHelper.getMediaHttpUrl(path) registra o caminho real (que pode ser
uma URI content://, uma URL remota ou um caminho de arquivo simples) sob um ID curto
e retorna http://<ip-do-dispositivo>:<porta>/media/<id>.<ext>. A rota então
se ramifica com base no tipo de origem:
when {
path.isUrl() -> call.proxyUrl(path) // URL remota: transmite resposta upstream
isContentUri(path) -> call.respondStream { sink -> streamContentUri(path, sink) }
path.isImageFast() -> call.respondFile(path) // imagens: serviço estático simples
else -> call.respondDlnaFile(path) // áudio/vídeo: serviço com consciente DLNA
}
respondDlnaFile é o caso interessante — muitas smart TVs e renderizadores
DLNA se recusam a reproduzir um fluxo a menos que ele pareça uma resposta
adequada de servidor de mídia 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) // alguns sistemas de TV só aceitam 206
}
applicationCall.respond(LocalFileContent(file))
return true
}
Observe que o status é sempre 206 Partial Content, não 200 OK — alguns
firmwares de TV tratam uma resposta 200 simples como "não buscável" e se recusam a
reproduzi-la, mesmo para um GET de arquivo completo. Esta única escolha de código de status
é a diferença entre "reproduz perfeitamente" e "TV mostra um spinner para sempre" em
vários dispositivos reais.
A mesma rota também serve capas de álbum para itens de áudio transmitidos:
UrlHelper.getAlbumArtHttpUrl() mapeia uma URI content://media/.../albumart/<id>
para o caminho idêntico /media/{id}, de modo que o streaming content:// e o
serviço de arquivo DLNA compartilhem um caminho de código, independentemente de a "mídia"
em questão ser a música ou sua imagem de capa.
Eventos GENA: Callbacks de Estado de Reprodução
Após iniciar a reprodução, o remetente assina o serviço de eventos AVTransport do renderizador (GENA — General Event Notification Architecture) para ser informado sobre mudanças de estado sem precisar fazer 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 são métodos HTTP personalizados (não fazem parte do
conjunto de verbos padrão), manipulados através do construtor de requisição genérico
HttpMethod("SUBSCRIBE") do Ktor. O renderizador então envia NOTIFY para
callbackUrl — a própria rota /callback/cast do PlainApp — sempre que
o estado de transporte, posição ou duração muda:
if (xml.contains("TransportState val=\"STOPPED\"") && !xml.contains("AVTransportURIMetaData")) {
// avança para o próximo item da lista de reprodução
} else if (xml.contains("TransportState val=\"PLAYING\"")) {
CastPlayer.isPlaying.value = true
}
A proteção contra callback duplicado
Alguns renderizadores enviam dois callbacks NOTIFY em rápida sucessão para a
mesma transição STOPPED — o segundo carrega
AVTransportURIMetaData enquanto o primeiro não. Avançar a lista de reprodução
em ambos pularia uma faixa toda vez que a reprodução parasse naturalmente. A
verificação !xml.contains("AVTransportURIMetaData") é um filtro deliberado e
restrito: apenas a primeira notificação STOPPED (sem metadados)
aciona o avanço automático. Uma tarefa startPositionUpdater() também consulta
GetPositionInfo a cada segundo como fallback, já que o próprio reconhecimento
SUBSCRIBE do PlainApp não envia eventos de volta para outros
controladores — apenas o lado remetente consome callbacks GENA das TVs.
Modo Receptor: Tornando-se um MediaRenderer UPnP
Invertendo a direção: qualquer controlador DLNA (um aplicativo de controle remoto de TV, VLC, outro
PlainApp) pode enviar mídia para o próprio telefone. DlnaReceiverEngine abre
o mesmo tipo de servidor HTTP + SSDP descrito acima, mas agora como o
MediaRenderer sendo controlado, em vez do controlador.
DlnaHttpRouter.route() serve description.xml (construído por
DlnaXmlTemplates.deviceDescription(), listando um serviço AVTransport e um
serviço RenderingControl como stub) e despacha ações SOAP para
DlnaSoapHandler. Uma chamada SetAVTransportURI não reproduz nada
imediatamente — ela armazena um PendingCastRequest e aguarda:
if (uri.isNotEmpty()) {
DlnaRendererState.rawPendingCastRequest.value =
PendingCastRequest(senderIp, senderName, uri, title, mediaType, albumArtUri)
DlnaRendererState.pendingPlayQueued.value = false
}
Um Play que chega antes da resolução da requisição pendente também não
inicia a reprodução — ele define pendingPlayQueued = true para que o comando
seja reproduzido automaticamente assim que a requisição de transmissão for aceita, em vez de ser
silenciosamente perdido:
val hasPending = DlnaRendererState.rawPendingCastRequest.value != null ||
DlnaRendererState.pendingCastRequest.value != null
if (hasPending) {
DlnaRendererState.pendingPlayQueued.value = true
} else {
DlnaRendererState.commandChannel.trySend(DlnaCommand.Play)
}
Comandos aceitos fluem através de um único Channel<DlnaCommand> que
DlnaReceiverViewModel consome, desacoplando a corrotina de manipulação bruta de socket
do estado da interface/player — o manipulador HTTP nunca toca diretamente no
ExoPlayer. A reprodução então é roteada para um de três composables de tela cheia
por DlnaMediaType: DlnaReceiverAudioPlayerContent (fundo gradiente,
capa do álbum, barra de busca), um visualizador de imagens, ou
DlnaReceiverVideoPlayerContent (ExoPlayer).
Segurança: Confiança do Remetente via Listas de Permissão/Negação
O DLNA não tem autenticação por design — qualquer dispositivo na LAN pode enviar
um SetAVTransportURI para um renderizador. Transformar um telefone pessoal em um
MediaRenderer não autenticado permitiria que qualquer pessoa no mesmo Wi-Fi (uma
rede de escritório compartilhada, a casa de um amigo, uma rede de convidados hostil) enviasse
URLs de mídia arbitrárias para ele. O PlainApp fecha essa lacuna com uma
lista de confiança por IP do remetente, bloqueando toda requisição de transmissão recebida:
DlnaRendererState.rawPendingCastRequest.filterNotNull().collect { pending ->
val allowed = DlnaAllowedSendersPreference.getAsync()
val denied = DlnaDeniedSendersPreference.getAsync()
when {
DlnaAllowedSendersPreference.containsIp(allowed, pending.senderIp) -> {
// Aceitação automática: envia comandos diretamente sem exibir diálogo
}
DlnaDeniedSendersPreference.containsIp(denied, pending.senderIp) -> {
// Rejeição automática: descarta silenciosamente
}
else -> {
// Remetente desconhecido: promove para estado visível na UI para o usuário decidir
DlnaRendererState.pendingCastRequest.value = pending
}
}
}
Uma requisição de transmissão de um remetente desconhecido exibe um diálogo de confirmação com
uma opção "lembrar desta escolha"; escolher lembrar escreve o
IP do remetente na preferência de permissão ou negação, para que requisições futuras do
mesmo endereço pulem o diálogo. Isso é aplicado inteiramente em
DlnaReceiverViewModel, acima do protocolo de rede — o manipulador SOAP
em si sempre retorna 200 OK independentemente da decisão de confiança (de acordo com a
especificação UPnP, a chamada de transporte foi bem-sucedida; se a mídia realmente
reproduz é uma decisão local separada).
Divisão de Plataforma: Orquestração em commonMain, Sockets em androidMain
Seguindo o mesmo padrão dos outros recursos de rede do PlainApp, apenas a
E/S de socket bruta em nível de byte é específica da plataforma. DlnaServerSocket e
DlnaSsdpSocket são interfaces expect; as implementações actual do Android
encapsulam java.net.ServerSocket e java.net.MulticastSocket
diretamente. Todo o resto — seleção de porta, cadência de alive/byebye do SSDP,
roteamento HTTP, análise SOAP, metadados DIDL-Lite e todas as quatro
transições de estado de DlnaCommand — reside em commonMain e pode ser testado
por unidade na JVM sem um dispositivo Android.
O iOS tem controle SOAP do lado remetente (é um cliente HTTP simples, sem sockets para implementar), mas o receptor é intencionalmente uma não-operação:
actual fun startDlnaRenderer() {}
O iOS não expõe uma maneira de executar um listener de multicast UDP em segundo plano
de forma confiável o suficiente para ser um bom cidadão MediaRenderer, então "Transmissão Sem Fio"
(recepção) é exclusiva do Android; "Transmitir para TV" (envio) funciona em ambos.
Notas de Engenharia em Kotlin Puro
Alguns detalhes de implementação existem especificamente para evitar dependências de plataforma que quebrariam o compartilhamento Kotlin Multiplatform:
- Geração de UUID v4.
DlnaReceiverEngine.randomUuid()implementa manualmente um UUID v4 RFC 4122 a partir deRandom.nextBytes(16)em vez dejava.util.UUID.randomUUID(), para que o gerador de identidade do dispositivo seja idêntico em todas as plataformas. - Decodificação percentual. O método privado
percentDecode()deDlnaSoapHandlersubstituijava.net.URLDecoder.decode()para títulos que chegam codificados por URL em uma URI de mídia. - Leitura de corpo HTTP em nível de byte.
Content-Lengthé uma contagem de bytes, não uma contagem de caracteres.AndroidDlnaClientConnection.readHttpRequest()lê o corpo viareadBodyBytes(bis, contentLength)diretamente doBufferedInputStreambruto, nunca umBufferedReader/CharArray— um título contendo UTF-8 multibyte (por exemplo, caracteres chineses, 3 bytes cada) caso contrário, leria menos que o corpo e travaria esperando por bytes que já chegaram, quebrando silenciosamenteSetAVTransportURIpara títulos não ASCII. - Análise de URL Base sem
java.net.URL.DlnaDevice.getBaseUrl()extrai o esquema/host/porta de um cabeçalhoLOCATIONcom fatias de string simples (substringAfter("://"),substringBefore('/')) em vez de construir umjava.net.URL.
Resumo dos Padrões de Projeto
| Padrão | Onde | Por quê |
|---|---|---|
| Protocolo Compartilhado, E/S Dividida | DlnaSoap/DlnaXmlTemplates em commonMain, sockets em androidMain | Formato de rede definido uma vez, a plataforma só fornece bytes de entrada/saída |
| Pendente → Verificação de Regra → Promoção | rawPendingCastRequest → pendingCastRequest | Separa "uma requisição chegou" de "uma requisição precisa de uma decisão humana" |
| Desacoplamento de Fila de Comandos | Channel<DlnaCommand> | A corrotina do manipulador HTTP nunca toca no estado do ExoPlayer/UI diretamente |
| Repetição de Comando Enfileirado | pendingPlayQueued | Um Play que chega antes da aprovação de SetAVTransportURI não é descartado |
| Código de Status Deliberado | respondDlnaFile → sempre 206 | Corresponde ao que o firmware real de TV espera, não apenas ao mínimo da especificação 200 |
| Filtro Restrito de Duplicatas | !xml.contains("AVTransportURIMetaData") | Distingue os dois callbacks STOPPED que alguns renderizadores enviam, sem um mecanismo genérico de dedup |
| Byebye Imediato | stop() envia ssdp:byebye antes de cancelar | Evita uma entrada de cache SSDP obsoleta de 30 minutos após o usuário desligar o recurso |
| Abrir no Desconhecido, Fechar por Padrão | preferência de permissão/negação + diálogo | Nem confia automaticamente nem bloqueia automaticamente um remetente pela primeira vez — um humano decide uma vez |
Leitura Adicional
- UPnP Device Architecture — a especificação subjacente SSDP/GENA/SOAP.
- Para o recurso de espelhamento de tela de baixa latência baseado em WebSocket (um caminho de transmissão diferente, não DLNA), consulte Screen Mirror.