Torna al blog
Transport16 min read

DLNA Cast: Costruire un Mittente e un Ricevitore UPnP da Zero

Come PlainApp implementa il DLNA/UPnP AV su entrambi i lati: scansionare e controllare TV via SOAP come mittente, e trasformare il telefono stesso in un MediaRenderer UPnP come ricevitore — con SSDP discovery, metadati DIDL-Lite, media serving con Range request, callback GENA event, e un modello di fiducia basato su IP mittente allow/deny, tutto in puro Kotlin Multiplatform.

DLNA (basato su UPnP AV) è il protocollo dietro al pulsante "Cast su TV" delle smart TV. Funziona interamente sulla rete locale, senza account cloud e senza passaggi di abbinamento. PlainApp implementa entrambe le direzioni: può inviare un video, una canzone o una foto locale a qualsiasi smart TV compatibile con DLNA, e può trasformare il telefono stesso in un MediaRenderer così che un'app per telecomando TV, VLC o un'altra istanza di PlainApp possa fare cast verso di esso.

Questo articolo copre il protocollo di rete (SSDP + SOAP + DIDL-Lite), il server HTTP locale che trasmette i media con le intestazioni richieste dai televisori, la sottoscrizione eventi GENA per lo stato di riproduzione e il modello di fiducia basato sull'IP del mittente che mantiene sicuro su un telefono moderno un protocollo non autenticato risalente agli anni '90.

Table of Contents

High-Level Architecture

Diagram 1
1

DLNA/UPnP AV non ha un server centrale né componenti cloud — tutto avviene tramite multicast UDP (scoperta) e HTTP (controllo + media) sulla rete locale. PlainApp implementa due ruoli indipendenti che condividono lo stesso codice protocollare in commonMain:

RuoloCosa faClassi principali
Mittente ("Cast su TV")Scansiona i renderer, dice a uno di recuperare un URL, controlla la riproduzioneDlnaDeviceScanner, DlnaTransportController, CastPlayer
Ricevitore ("Wireless Cast")Si pubblicizza come MediaRenderer, accetta comandi da qualsiasi controller UPnPDlnaReceiverEngine, DlnaHttpRouter, DlnaSoapHandler, DlnaReceiverViewModel

Entrambi i ruoli riutilizzano DlnaSoap (costruttori di envelope SOAP) e DlnaDevice (il modello di dispositivo UPnP) da features/dlna/common/. La specifica DLNA non prevede alcuna autenticazione — chiunque sulla LAN che conosca l'URL di controllo di un renderer può inviargli comandi. Questo influenza quasi ogni decisione di progettazione descritta di seguito, in particolare il modello di fiducia del ricevitore.

SSDP Discovery: Trovare Dispositivi Senza un Server

La scoperta utilizza SSDP (Simple Service Discovery Protocol), un sottile strato sopra il multicast UDP su 239.255.255.250:1900. Non esiste un server di directory — i dispositivi si annunciano da soli e rispondono direttamente alle query di ricerca.

Diagram 2
2

Come mittente, DlnaDeviceScanner invia in broadcast un datagramma M-SEARCH con target urn:schemas-upnp-org:service:AVTransport:1 e raccoglie le risposte unicast 200 OK, ciascuna con un'intestazione LOCATION che punta al description.xml del renderer. Lo scanner deduplica in base a hostAddress — non analizza l'XML del dispositivo; questo compito è lasciato a CastViewModel.searchAsync(), che recupera LOCATION, chiama device.update(xml) e mostra solo i dispositivi per cui device.isAVTransport() restituisce 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)
    }
}

Come ricevitore, DlnaReceiverEngine.runSsdpLoop() invia tre datagrammi NOTIFY ssdp:alive all'avvio (dispositivo principale, tipo di dispositivo MediaRenderer:1, tipo di servizio AVTransport:1), riannuncia ogni 30 secondi (CACHE-CONTROL: max-age=1800) e risponde alle richieste M-SEARCH in arrivo con risposte unicast. Alla chiamata di stop(), invia immediatamente ssdp:byebye invece di attendere la scadenza della cache di 30 minuti — così un'app per telecomando TV smette di elencare PlainApp nel momento in cui "Wireless Cast" viene disattivato.

Port fallback

Il server HTTP del ricevitore prova prima la porta 7878, poi 7879, poi 7880, preferendo l'ultima porta utilizzata con successo:

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 tutte e tre le porte sono occupate (raro, ma possibile con altre app DLNA in esecuzione), startError viene impostato e mostrato nell'interfaccia utente invece di fallire silenziosamente.

AVTransport Control: Il Protocollo SOAP

Una volta trovato un renderer, la riproduzione viene controllata tramite UPnP AVTransport, un servizio SOAP su HTTP. Ogni azione è una POST all'URL di controllo del renderer con un'intestazione SOAPAction e un corpo XML racchiuso in un envelope SOAP.

Diagram 3
3

DlnaTransportController costruisce ogni richiesta con un helper condiviso:

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 imposta SOAPAction: "<serviceType>#<action>" e spedisce DlnaSoap.requestEnvelope(soapBody) — le stesse costanti di envelope utilizzate dal lato ricevitore per costruire le risposte, così il formato wire deve essere definito una sola volta in commonMain.

Il DlnaHttpRouter.handleSoap() del ricevitore rispecchia questo comportamento dall'altra parte: legge l'intestazione soapaction, estrae il nome dell'azione dopo il # e smista in base ad essa — SetAVTransportURI, Play, Pause, Stop, Seek, GetTransportInfo, GetPositionInfo, GetMediaInfo, GetDeviceCapabilities. RenderingControl (volume) è lasciato come stub che restituisce un valore fisso di 100 — PlainApp non espone il volume del dispositivo tramite UPnP.

Metadati DIDL-Lite e la Stranezza del Double-Escaping

SetAVTransportURI trasporta due parametri: CurrentURI (l'URL del media) e CurrentURIMetaData — un frammento XML DIDL-Lite che descrive il titolo, la classe del media e l'album art, incorporato come stringa con escape XML all'interno del corpo SOAP esterno:

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

L'XML DIDL-Lite viene escapato due volte: una volta per il testo del titolo (così una canzone chiamata Fire & Ice non rompe i tag DIDL-Lite), e una seconda volta per l'intero documento DIDL-Lite (così i suoi </> non rompono l'envelope SOAP esterno in cui è incorporato come testo). Questa è una caratteristica ben nota di UPnP, non un bug — CurrentURIMetaData è definito come contenuto testuale, non come elementi XML annidati.

Sul lato ricevitore, DlnaSoapHandler inverte il processo: parseSoapAction unescapa il corpo SOAP una volta per ottenere il testo DIDL-Lite, poi extractTitleFromDidlMeta/extractMediaTypeFromDidlMeta/ extractAlbumArtUriFromDidlMeta eseguono ciascuna un secondo passaggio di unescaping delle entità ed estrazione dei tag su quella stringa 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> è assente (alcuni mittenti lo omettono), cleanMediaTitle() ricade sull'estensione del file dell'URI stesso — il rilevamento del tipo di media non fallisce mai in modo irreversibile, si degrada semplicemente a UNKNOWN che porta al lettore video come predefinito sicuro.

Servire Media alla TV: Range Request e Intestazioni DLNA

Una chiamata SetAVTransportURI dice solo al renderer dove recuperare il media — i byte veri e propri sono serviti dal server HTTP locale di PlainApp stesso, all'indirizzo /media/{id}.

Diagram 4
4

UrlHelper.getMediaHttpUrl(path) registra il percorso reale (che potrebbe essere un URI content://, un URL remoto o un semplice percorso file) sotto un ID breve e restituisce http://<ip-dispositivo>:<porta>/media/<id>.<ext>. La route poi si ramifica a seconda del tipo di sorgente:

when {
    path.isUrl() -> call.proxyUrl(path)                 // URL remoto: stream della risposta upstream
    isContentUri(path) -> call.respondStream { sink -> streamContentUri(path, sink) }
    path.isImageFast() -> call.respondFile(path)         // immagini: servizio statico semplice
    else -> call.respondDlnaFile(path)                   // audio/video: servizio DLNA-aware
}

respondDlnaFile è il caso interessante — molti smart TV e renderer DLNA rifiutano di riprodurre uno stream a meno che non assomigli a una risposta di un server media DLNA appropriato:

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) // alcuni sistemi TV accettano solo 206
    }
    applicationCall.respond(LocalFileContent(file))
    return true
}

Nota che lo stato è sempre 206 Partial Content, non 200 OK — alcuni firmware TV trattano una risposta 200 semplice come "non ricercabile" e rifiutano di riprodurla, anche per una GET di file intero. Questa singola scelta di codice di stato è la differenza tra "funziona perfettamente" e "la TV mostra un caricatore infinito" su diversi dispositivi reali.

La stessa route serve anche l'album art per gli elementi audio in cast: UrlHelper.getAlbumArtHttpUrl() mappa un URI content://media/.../albumart/<id> sullo stesso percorso /media/{id}, quindi lo streaming content:// e il servizio file DLNA condividono un unico percorso di codice indipendentemente dal fatto che il "media" in questione sia la canzone o la sua copertina.

Eventi GENA: Callback sullo Stato di Riproduzione

Dopo aver avviato la riproduzione, il mittente si sottoscrive al servizio eventi AVTransport del renderer (GENA — General Event Notification Architecture) per ricevere notifiche sui cambiamenti di stato senza 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 sono metodi HTTP personalizzati (non presenti nel set di verbi standard), gestiti tramite il costruttore generico di richieste HttpMethod("SUBSCRIBE") di Ktor. Il renderer quindi invia NOTIFY a callbackUrl — la route /callback/cast di PlainApp — ogni volta che lo stato di trasporto, la posizione o la durata cambiano:

if (xml.contains("TransportState val=\"STOPPED\"") && !xml.contains("AVTransportURIMetaData")) {
    // passa all'elemento successivo della playlist
} else if (xml.contains("TransportState val=\"PLAYING\"")) {
    CastPlayer.isPlaying.value = true
}

La protezione dai callback duplicati

Alcuni renderer inviano due callback NOTIFY in rapida successione per la stessa transizione STOPPED — la seconda si trova a trasportare AVTransportURIMetaData mentre la prima no. Avanzare la playlist su entrambe saltherebbe un brano ogni volta che la riproduzione termina naturalmente. Il controllo !xml.contains("AVTransportURIMetaData") è un filtro deliberato e mirato: solo la prima notifica STOPPED (senza metadati) attiva l'avanzamento automatico. Un job startPositionUpdater() esegue anche il polling di GetPositionInfo ogni secondo come ripiego, poiché la conferma SUBSCRIBE di PlainApp non invia eventi ad altri controller — solo il lato mittente consuma i callback GENA dai televisori.

Modalità Ricevitore: Diventare un UPnP MediaRenderer

Invertiamo la direzione: qualsiasi controller DLNA (un'app per telecomando TV, VLC, un'altra istanza di PlainApp) può inviare media al telefono stesso. DlnaReceiverEngine apre lo stesso tipo di server HTTP + SSDP descritto in precedenza, ma questa volta come MediaRenderer controllato anziché come controllore.

DlnaHttpRouter.route() serve description.xml (costruito da DlnaXmlTemplates.deviceDescription(), che elenca un servizio AVTransport e uno stub RenderingControl) e smista le azioni SOAP a DlnaSoapHandler. Una chiamata SetAVTransportURI non riproduce nulla immediatamente — memorizza un PendingCastRequest e attende:

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

Un Play che arriva prima che la richiesta in sospeso venga risolta non avvia la riproduzione — imposta pendingPlayQueued = true in modo che il comando venga rieseguito automaticamente una volta che la richiesta di cast viene accettata, invece di essere perso silenziosamente:

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

I comandi accettati fluiscono attraverso un singolo Channel<DlnaCommand> che DlnaReceiverViewModel consuma, disaccoppiando la coroutine di gestione del socket grezzo dallo stato UI/player — il gestore HTTP non tocca mai ExoPlayer direttamente. La riproduzione viene poi instradata a uno dei tre composable full-screen in base a DlnaMediaType: DlnaReceiverAudioPlayerContent (sfondo gradiente, album art, barra di avanzamento), un visualizzatore di immagini o DlnaReceiverVideoPlayerContent (ExoPlayer).

Sicurezza: Fiducia del Mittente tramite Liste Allow/Deny

DLNA non ha autenticazione per progettazione — qualsiasi dispositivo sulla LAN può inviare a un renderer un SetAVTransportURI. Trasformare un telefono personale in un MediaRenderer non autenticato permetterebbe a chiunque sulla stessa rete Wi-Fi (una rete aziendale condivisa, la casa di un amico, una rete ospite ostile) di inviargli URL media arbitrari. PlainApp colma questa lacuna con una lista di fiducia basata sull'IP del mittente, filtrando ogni richiesta di cast in arrivo:

Diagram 5
5

DlnaRendererState.rawPendingCastRequest.filterNotNull().collect { pending ->
    val allowed = DlnaAllowedSendersPreference.getAsync()
    val denied = DlnaDeniedSendersPreference.getAsync()
    when {
        DlnaAllowedSendersPreference.containsIp(allowed, pending.senderIp) -> {
            // Accettazione automatica: invia i comandi direttamente senza mostrare un dialogo
        }
        DlnaDeniedSendersPreference.containsIp(denied, pending.senderIp) -> {
            // Rifiuto automatico: scarta silenziosamente
        }
        else -> {
            // Mittente sconosciuto: promuove a stato visibile nell'UI per la decisione dell'utente
            DlnaRendererState.pendingCastRequest.value = pending
        }
    }
}

Una richiesta di cast da un mittente sconosciuto mostra un dialogo di conferma con un flag opzionale "ricorda questa scelta"; scegliere di ricordare scrive l'IP del mittente nella preferenza allow o deny in modo che le richieste future dallo stesso indirizzo saltino il dialogo. Questo viene applicato interamente in DlnaReceiverViewModel, sopra il protocollo wire — il gestore SOAP stesso restituisce sempre 200 OK indipendentemente dalla decisione di fiducia (secondo la specifica UPnP, la chiamata di trasporto è riuscita; se il media venga effettivamente riprodotto è una decisione locale separata).

Separazione su Piattaforma: commonMain Orchestrazione, androidMain Socket

Diagram 6
6

Seguendo lo stesso pattern delle altre funzionalità di rete di PlainApp, solo l'I/O socket a livello di byte grezzo è specifico della piattaforma. DlnaServerSocket e DlnaSsdpSocket sono interfacce expect; le implementazioni actual su Android incapsulano direttamente java.net.ServerSocket e java.net.MulticastSocket. Tutto il resto — selezione della porta, cadenza SSDP alive/byebye, routing HTTP, parsing SOAP, metadati DIDL-Lite e tutte e quattro le transizioni di stato DlnaCommand — risiede in commonMain ed è testabile unitariamente sulla JVM senza un dispositivo Android.

iOS ha il controllo SOAP lato mittente (è un semplice client HTTP, nessun socket da implementare), ma il ricevitore è deliberatamente un no-op:

actual fun startDlnaRenderer() {}

iOS non espone un modo per eseguire un listener UDP multicast in background in modo sufficientemente affidabile per essere un buon cittadino MediaRenderer, quindi "Wireless Cast" (ricezione) è solo Android; "Cast su TV" (invio) funziona su entrambi.

Note di Ingegneria sul Kotlin Puro

Alcuni dettagli implementativi esistono specificamente per evitare dipendenze di piattaforma che romperebbero la condivisione in Kotlin Multiplatform:

  • Generazione UUID v4. DlnaReceiverEngine.randomUuid() costruisce manualmente un UUID RFC 4122 v4 da Random.nextBytes(16) invece di java.util.UUID.randomUUID(), così il generatore di identità del dispositivo è identico su ogni piattaforma.
  • Percent-decoding. Il percentDecode() privato di DlnaSoapHandler sostituisce java.net.URLDecoder.decode() per i titoli che arrivano codificati in URL in un URI del media.
  • Lettura del corpo HTTP a livello di byte. Content-Length è un conteggio di byte, non di caratteri. AndroidDlnaClientConnection.readHttpRequest() legge il corpo tramite readBodyBytes(bis, contentLength) sul BufferedInputStream grezzo, mai usando un BufferedReader/CharArray — un titolo contenente UTF-8 multi-byte (ad esempio caratteri cinesi, 3 byte ciascuno) altrimenti leggerebbe meno byte del necessario e si bloccherebbe in attesa di byte già arrivati, rompendo silenziosamente SetAVTransportURI per titoli non ASCII.
  • Parsing dell'URL base senza java.net.URL. DlnaDevice.getBaseUrl() estrae schema/host/porta da un'intestazione LOCATION con semplice manipolazione di stringhe (substringAfter("://"), substringBefore('/')) invece di costruire un java.net.URL.

Riepilogo dei Pattern di Progettazione

PatternDovePerché
Protocollo Condiviso, I/O DivisoDlnaSoap/DlnaXmlTemplates in commonMain, socket in androidMainFormato wire definito una volta, la piattaforma fornisce solo byte in entrata/uscita
In Sospeso → Controllo Regole → PromozionerawPendingCastRequestpendingCastRequestSepara "una richiesta è arrivata" da "una richiesta necessita di una decisione umana"
Disaccoppiamento tramite Coda di ComandiChannel<DlnaCommand>La coroutine del gestore HTTP non tocca mai direttamente ExoPlayer/stato UI
Riproduzione Comandi in CodapendingPlayQueuedUn Play che arriva prima dell'approvazione di SetAVTransportURI non viene perso
Codice di Stato DeliberatorespondDlnaFile → sempre 206Corrisponde a ciò che il firmware reale dei TV si aspetta, non solo il minimo 200 della specifica
Filtro Duplicati Stretto!xml.contains("AVTransportURIMetaData")Distingue i due callback STOPPED che alcuni renderer inviano, senza un meccanismo di deduplicazione generico
Byebye Immediatostop() invia ssdp:byebye prima di cancellareEvita una voce SSDP obsoleta di 30 minuti dopo che l'utente disattiva la funzionalità
Aperto per Sconosciuti, Chiuso per Defaultpreferenza allow/deny + dialogoNon si fida automaticamente né blocca automaticamente un mittente alla prima occorrenza — un umano decide una volta

Ulteriori Letture

  • UPnP Device Architecture — la specifica sottostante SSDP/GENA/SOAP.
  • Per la funzionalità di screen mirroring a bassa latenza basata su WebSocket (un percorso di cast diverso, non DLNA), consulta Screen Mirror.