Voltar ao blog
Transport16 min read

DLNA Cast: Construindo um Remetente e Receptor UPnP do Zero

Como o PlainApp implementa o DLNA/UPnP AV em ambos os lados: escaneando e controlando TVs via SOAP como remetente, e transformando o próprio telefone em um MediaRenderer UPnP como receptor — com descoberta SSDP, metadados DIDL-Lite, serviço de mídia com suporte a Range Request, callbacks de eventos GENA e um modelo de confiança baseado em lista de permissão/negação por IP do remetente, tudo em Kotlin Multiplatform puro.

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

Arquitetura de Alto Nível

Diagram 1
1

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:

PapelO que fazClasses principais
Remetente ("Transmitir para TV")Escaneia por renderizadores, instrui um a buscar uma URL, controla a reproduçãoDlnaDeviceScanner, DlnaTransportController, CastPlayer
Receptor ("Transmissão Sem Fio")Anuncia-se como um MediaRenderer, aceita controle de qualquer controlador UPnPDlnaReceiverEngine, 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.

Diagram 2
2

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.

Diagram 3
3

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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
}

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}.

Diagram 4
4

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:

Diagram 5
5

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

Diagram 6
6

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 de Random.nextBytes(16) em vez de java.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() de DlnaSoapHandler substitui java.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 via readBodyBytes(bis, contentLength) diretamente do BufferedInputStream bruto, nunca um BufferedReader/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 silenciosamente SetAVTransportURI para títulos não ASCII.
  • Análise de URL Base sem java.net.URL. DlnaDevice.getBaseUrl() extrai o esquema/host/porta de um cabeçalho LOCATION com fatias de string simples (substringAfter("://"), substringBefore('/')) em vez de construir um java.net.URL.

Resumo dos Padrões de Projeto

PadrãoOndePor quê
Protocolo Compartilhado, E/S DivididaDlnaSoap/DlnaXmlTemplates em commonMain, sockets em androidMainFormato de rede definido uma vez, a plataforma só fornece bytes de entrada/saída
Pendente → Verificação de Regra → PromoçãorawPendingCastRequestpendingCastRequestSepara "uma requisição chegou" de "uma requisição precisa de uma decisão humana"
Desacoplamento de Fila de ComandosChannel<DlnaCommand>A corrotina do manipulador HTTP nunca toca no estado do ExoPlayer/UI diretamente
Repetição de Comando EnfileiradopendingPlayQueuedUm Play que chega antes da aprovação de SetAVTransportURI não é descartado
Código de Status DeliberadorespondDlnaFile → sempre 206Corresponde 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 Imediatostop() envia ssdp:byebye antes de cancelarEvita uma entrada de cache SSDP obsoleta de 30 minutos após o usuário desligar o recurso
Abrir no Desconhecido, Fechar por Padrãopreferência de permissão/negação + diálogoNem 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.