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
- Table of Contents
- High-Level Architecture
- SSDP Discovery: Trovare Dispositivi Senza un Server
- AVTransport Control: Il Protocollo SOAP
- Metadati DIDL-Lite e la Stranezza del Double-Escaping
- Servire Media alla TV: Range Request e Intestazioni DLNA
- Eventi GENA: Callback sullo Stato di Riproduzione
- Modalità Ricevitore: Diventare un UPnP MediaRenderer
- Sicurezza: Fiducia del Mittente tramite Liste Allow/Deny
- Separazione su Piattaforma: commonMain Orchestrazione, androidMain Socket
- Note di Ingegneria sul Kotlin Puro
- Riepilogo dei Pattern di Progettazione
- Ulteriori Letture
High-Level Architecture
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:
| Ruolo | Cosa fa | Classi principali |
|---|---|---|
| Mittente ("Cast su TV") | Scansiona i renderer, dice a uno di recuperare un URL, controlla la riproduzione | DlnaDeviceScanner, DlnaTransportController, CastPlayer |
| Ricevitore ("Wireless Cast") | Si pubblicizza come MediaRenderer, accetta comandi da qualsiasi controller UPnP | DlnaReceiverEngine, 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.
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.
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("&", "&").replace("<", "<").replace(">", ">")
}
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}.
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:
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
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 daRandom.nextBytes(16)invece dijava.util.UUID.randomUUID(), così il generatore di identità del dispositivo è identico su ogni piattaforma. - Percent-decoding. Il
percentDecode()privato diDlnaSoapHandlersostituiscejava.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 tramitereadBodyBytes(bis, contentLength)sulBufferedInputStreamgrezzo, mai usando unBufferedReader/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 silenziosamenteSetAVTransportURIper titoli non ASCII. - Parsing dell'URL base senza
java.net.URL.DlnaDevice.getBaseUrl()estrae schema/host/porta da un'intestazioneLOCATIONcon semplice manipolazione di stringhe (substringAfter("://"),substringBefore('/')) invece di costruire unjava.net.URL.
Riepilogo dei Pattern di Progettazione
| Pattern | Dove | Perché |
|---|---|---|
| Protocollo Condiviso, I/O Diviso | DlnaSoap/DlnaXmlTemplates in commonMain, socket in androidMain | Formato wire definito una volta, la piattaforma fornisce solo byte in entrata/uscita |
| In Sospeso → Controllo Regole → Promozione | rawPendingCastRequest → pendingCastRequest | Separa "una richiesta è arrivata" da "una richiesta necessita di una decisione umana" |
| Disaccoppiamento tramite Coda di Comandi | Channel<DlnaCommand> | La coroutine del gestore HTTP non tocca mai direttamente ExoPlayer/stato UI |
| Riproduzione Comandi in Coda | pendingPlayQueued | Un Play che arriva prima dell'approvazione di SetAVTransportURI non viene perso |
| Codice di Stato Deliberato | respondDlnaFile → sempre 206 | Corrisponde 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 Immediato | stop() invia ssdp:byebye prima di cancellare | Evita una voce SSDP obsoleta di 30 minuti dopo che l'utente disattiva la funzionalità |
| Aperto per Sconosciuti, Chiuso per Default | preferenza allow/deny + dialogo | Non 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.