DLNA (построенный на UPnP AV) — это протокол, стоящий за кнопкой "Cast to TV" на
современных телевизорах. Он работает полностью в локальной сети, не требует
облачного аккаунта и никаких шагов по сопряжению. PlainApp реализует оба направления
этого протокола: он может отправлять локальное видео, музыку или фото на любой
DLNA-совместимый телевизор, а также превращать сам телефон в MediaRenderer,
чтобы приложение пульта телевизора, VLC или другой PlainApp могли кастить на него.
В этой статье рассматриваются проводной протокол (SSDP + SOAP + DIDL-Lite), локальный HTTP-сервер, который стримит медиа с заголовками, требуемыми телевизорами, подписка на события GENA для отслеживания состояния воспроизведения и модель доверия на основе IP-отправителя, которая позволяет безопасно использовать неаутентифицированный протокол 90-х годов на современном телефоне.
Содержание
- Содержание
- Высокоуровневая архитектура
- SSDP-обнаружение: поиск устройств без сервера
- Управление AVTransport: протокол SOAP
- DIDL-Lite метаданные и особенность двойного экранирования
- Передача медиа на телевизор: Range-запросы и DLNA-заголовки
- События GENA: колбэки состояния воспроизведения
- Режим получателя: превращение в UPnP MediaRenderer
- Безопасность: доверие отправителям через списки разрешений/запретов
- Разделение платформ: commonMain для оркестрации, androidMain для сокетов
- Заметки по инженерии на чистом Kotlin
- Обзор паттернов проектирования
- Дополнительные материалы
Высокоуровневая архитектура
В DLNA/UPnP AV нет центрального сервера и облачных компонентов — всё происходит через UDP-мультикаст в локальной сети (обнаружение) и HTTP (управление + медиа). PlainApp реализует две независимые роли, которые используют один и тот же код протокола в commonMain:
| Роль | Что делает | Ключевые классы |
|---|---|---|
| Отправитель ("Cast to TV") | Сканирует реендеры, указывает одному загрузить URL, управляет воспроизведением | DlnaDeviceScanner, DlnaTransportController, CastPlayer |
| Получатель ("Wireless Cast") | Анонсирует себя как MediaRenderer, принимает управление от любого UPnP-контроллера | DlnaReceiverEngine, DlnaHttpRouter, DlnaSoapHandler, DlnaReceiverViewModel |
Обе роли используют DlnaSoap (построители SOAP-конвертов) и DlnaDevice (модель
UPnP-устройства) из features/dlna/common/. В спецификации DLNA нет никакой
аутентификации — любой в локальной сети, знающий управляющий URL реендера,
может отправлять ему команды. Это определяет почти все описанные ниже проектные
решения, особенно модель доверия для получателя.
SSDP-обнаружение: поиск устройств без сервера
Обнаружение использует SSDP (Simple Service Discovery Protocol) — тонкий слой
поверх UDP-мультикаста на 239.255.255.250:1900. Здесь нет сервера каталогов —
устройства анонсируют себя и отвечают на поисковые запросы напрямую.
В роли отправителя, DlnaDeviceScanner отправляет широковещательную дейтаграмму
M-SEARCH с целью urn:schemas-upnp-org:service:AVTransport:1 и собирает
одноадресные ответы 200 OK, каждый из которых содержит заголовок LOCATION,
указывающий на description.xml реендера. Сканер удаляет дубликаты по
hostAddress — он сам не парсит XML устройства; это делегировано
CastViewModel.searchAsync(), который загружает LOCATION, вызывает
device.update(xml) и показывает только те устройства, у которых
device.isAVTransport() возвращает 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)
}
}
В роли получателя, DlnaReceiverEngine.runSsdpLoop() отправляет три
дейтаграммы NOTIFY ssdp:alive при запуске (корневое устройство, тип устройства
MediaRenderer:1, тип сервиса AVTransport:1), повторяет анонс каждые 30 секунд
(CACHE-CONTROL: max-age=1800) и отвечает на входящие M-SEARCH запросы
одноадресными ответами. При stop() он немедленно отправляет ssdp:byebye,
не дожидаясь истечения 30-минутного кэша — чтобы приложение пульта перестало
показывать PlainApp, как только пользователь выключит "Wireless Cast".
Резервный порт
HTTP-сервер получателя сначала пробует порт 7878, затем 7879, затем
7880, отдавая предпочтение порту, который работал в последний раз:
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
}
Если все три порта заняты (редко, но возможно при работе других DLNA-приложений),
устанавливается startError и отображается в интерфейсе, а не происходит
тихая ошибка.
Управление AVTransport: протокол SOAP
После обнаружения реендера воспроизведением управляет UPnP AVTransport —
сервис SOAP поверх HTTP. Каждое действие — это POST на управляющий URL
реендера с заголовком SOAPAction и XML-телом, обёрнутым в SOAP-конверт.
DlnaTransportController строит каждый запрос с помощью общего вспомогательного
метода:
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 устанавливает заголовок SOAPAction: "<serviceType>#<action>"
и отправляет DlnaSoap.requestEnvelope(soapBody) — те же константы конверта
используются на стороне получателя для построения ответов, поэтому формат
проводного протокола нужно определить только один раз в commonMain.
DlnaHttpRouter.handleSoap() на стороне получателя зеркально отражает это:
он читает заголовок soapaction, извлекает имя действия после # и
диспетчеризует его — SetAVTransportURI, Play, Pause, Stop,
Seek, GetTransportInfo, GetPositionInfo, GetMediaInfo,
GetDeviceCapabilities. RenderingControl (громкость) реализован как заглушка,
всегда возвращающая 100 — PlainApp не предоставляет управление громкостью
устройства через UPnP.
DIDL-Lite метаданные и особенность двойного экранирования
SetAVTransportURI передаёт два параметра: CurrentURI (URL медиа) и
CurrentURIMetaData — фрагмент XML в формате DIDL-Lite, описывающий
название, класс медиа и обложку альбома, встроенный как XML-экранированная
строка внутрь внешнего SOAP-тела:
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(">", ">")
}
DIDL-Lite XML экранируется дважды: один раз для самого текста названия
(чтобы песня под названием Fire & Ice не сломала DIDL-Lite теги), и второй раз
для всего DIDL-Lite документа (чтобы его собственные </> не сломали
внешний SOAP-конверт, в который он встроен как текст). Это известная
особенность UPnP, а не ошибка — CurrentURIMetaData определён как строковое
содержимое, а не как вложенные XML-элементы.
На стороне получателя DlnaSoapHandler выполняет обратную операцию:
parseSoapAction сначала делает одно экранирование SOAP-тела, чтобы получить
текст DIDL-Lite, затем extractTitleFromDidlMeta/extractMediaTypeFromDidlMeta/
extractAlbumArtUriFromDidlMeta выполняют второй проход экранирования
сущностей и извлечения тегов на этой внутренней строке:
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
}
}
Если <upnp:class> отсутствует (некоторые отправители его опускают),
cleanMediaTitle() возвращается к расширению файла из самого URI —
определение типа медиа никогда не приводит к жёсткой ошибке, оно просто
понижается до UNKNOWN, который направляется в видеоплеер как безопасное
значение по умолчанию.
Передача медиа на телевизор: Range-запросы и DLNA-заголовки
Вызов SetAVTransportURI только сообщает реендеру, откуда загружать медиа —
фактические байты передаются собственным локальным HTTP-сервером PlainApp
по адресу /media/{id}.
UrlHelper.getMediaHttpUrl(path) регистрирует реальный путь (который может быть
URI content://, удалённым URL или обычным путём к файлу) под коротким
идентификатором и возвращает http://<ip-устройства>:<порт>/media/<id>.<ext>.
Затем маршрут ветвится в зависимости от типа источника:
when {
path.isUrl() -> call.proxyUrl(path) // удалённый URL: стримим ответ вышестоящего сервера
isContentUri(path) -> call.respondStream { sink -> streamContentUri(path, sink) }
path.isImageFast() -> call.respondFile(path) // изображения: простая статическая отдача
else -> call.respondDlnaFile(path) // аудио/видео: DLNA-совместимая отдача
}
respondDlnaFile — самый интересный случай. Многие современные телевизоры и
DLNA-реендеры отказываются воспроизводить поток, если он не выглядит как ответ
от настоящего 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) // некоторые ОС телевизоров принимают только 206
}
applicationCall.respond(LocalFileContent(file))
return true
}
Обратите внимание: статус всегда 206 Partial Content, а не 200 OK —
некоторые прошивки телевизоров воспринимают обычный ответ 200 как
"неподдерживающий перемотку" и отказываются воспроизводить, даже при GET-запросе
на полный файл. Этот единственный выбор кода статуса — разница между
"воспроизводится отлично" и "телевизор показывает крутилку вечно" на нескольких
реальных устройствах.
Тот же маршрут также отдаёт обложку альбома для аудиоэлементов кастинга:
UrlHelper.getAlbumArtHttpUrl() отображает URI вида content://media/.../albumart/<id>
в тот же путь /media/{id}, так что content:// стриминг и DLNA-обслуживание
файлов используют один и тот же код, независимо от того, является ли "медиа"
песней или её изображением обложки.
События GENA: колбэки состояния воспроизведения
После начала воспроизведения отправитель подписывается на сервис событий AVTransport реендера (GENA — General Event Notification Architecture), чтобы узнавать об изменениях состояния без опроса:
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 — это пользовательские HTTP-методы (не
входящие в стандартный набор глаголов), обрабатываемые через универсальный
построитель запросов HttpMethod("SUBSCRIBE") в Ktor. Затем реендер отправляет
NOTIFY на callbackUrl — собственный маршрут PlainApp /callback/cast —
всякий раз, когда изменяется состояние транспорта, позиция или длительность:
if (xml.contains("TransportState val=\"STOPPED\"") && !xml.contains("AVTransportURIMetaData")) {
// переход к следующему элементу плейлиста
} else if (xml.contains("TransportState val=\"PLAYING\"")) {
CastPlayer.isPlaying.value = true
}
Защита от дублирующихся колбэков
Некоторые реендеры отправляют два колбэка NOTIFY подряд для одного и того же
перехода STOPPED — второй случайно содержит AVTransportURIMetaData, а первый
нет. Переключение плейлиста при обоих приводило бы к пропуску трека каждый раз,
когда воспроизведение естественно останавливается. Проверка
!xml.contains("AVTransportURIMetaData") — это намеренный, узкий фильтр:
только первое уведомление STOPPED (без метаданных) вызывает авто-переключение.
Задача startPositionUpdater() также опрашивает GetPositionInfo каждую секунду
как запасной вариант, поскольку собственное подтверждение SUBSCRIBE от PlainApp
не отправляет события обратно другим контроллерам — только сторона отправителя
потребляет GENA-колбэки от телевизоров.
Режим получателя: превращение в UPnP MediaRenderer
Развернём направление: любой DLNA-контроллер (приложение пульта телевизора, VLC,
другой PlainApp) может отправлять медиа на сам телефон. DlnaReceiverEngine
открывает тот же тип HTTP + SSDP сервера, описанный выше, но теперь в роли
управляемого MediaRenderer, а не контроллера.
DlnaHttpRouter.route() отдаёт description.xml (построенный
DlnaXmlTemplates.deviceDescription(), перечисляющий сервис AVTransport и
заглушку RenderingControl) и диспетчеризует SOAP-действия в
DlnaSoapHandler. Вызов SetAVTransportURI не начинает воспроизведение
немедленно — он сохраняет PendingCastRequest и ждёт:
if (uri.isNotEmpty()) {
DlnaRendererState.rawPendingCastRequest.value =
PendingCastRequest(senderIp, senderName, uri, title, mediaType, albumArtUri)
DlnaRendererState.pendingPlayQueued.value = false
}
Play, поступивший до разрешения отложенного запроса, тоже не запускает
воспроизведение — он устанавливает pendingPlayQueued = true, чтобы команда
автоматически воспроизвелась после принятия запроса на кастинг, а не была
тихо потеряна:
val hasPending = DlnaRendererState.rawPendingCastRequest.value != null ||
DlnaRendererState.pendingCastRequest.value != null
if (hasPending) {
DlnaRendererState.pendingPlayQueued.value = true
} else {
DlnaRendererState.commandChannel.trySend(DlnaCommand.Play)
}
Принятые команды проходят через единый Channel<DlnaCommand>, который
потребляется DlnaReceiverViewModel, разделяя корутину обработки сырых сокетов
и состояние UI/плеера — HTTP-обработчик никогда не касается ExoPlayer
напрямую. Воспроизведение затем направляется в один из трёх полноэкранных
composable в зависимости от DlnaMediaType:
DlnaReceiverAudioPlayerContent (градиентный фон, обложка альбома, ползунок
позиции), просмотрщик изображений или DlnaReceiverVideoPlayerContent (ExoPlayer).
Безопасность: доверие отправителям через списки разрешений/запретов
В DLNA нет аутентификации по замыслу — любое устройство в локальной сети
может отправить реендеру команду SetAVTransportURI. Превращение личного
телефона в неаутентифицированный MediaRenderer позволило бы любому в той же
Wi-Fi-сети (общий офисный Wi-Fi, дом друга, враждебная гостевая сеть) отправлять
на него произвольные медиа-URL. PlainApp закрывает эту брешь с помощью списка
доверия по IP-адресу отправителя, проверяя каждый входящий запрос на кастинг:
DlnaRendererState.rawPendingCastRequest.filterNotNull().collect { pending ->
val allowed = DlnaAllowedSendersPreference.getAsync()
val denied = DlnaDeniedSendersPreference.getAsync()
when {
DlnaAllowedSendersPreference.containsIp(allowed, pending.senderIp) -> {
// Автопринятие: отправлять команды напрямую, без показа диалога
}
DlnaDeniedSendersPreference.containsIp(denied, pending.senderIp) -> {
// Автоотклонение: тихо игнорировать
}
else -> {
// Неизвестный отправитель: перевести в состояние, видимое в UI, для решения пользователя
DlnaRendererState.pendingCastRequest.value = pending
}
}
}
Запрос на кастинг от неизвестного отправителя вызывает диалог подтверждения
с опциональным флагом "запомнить этот выбор"; если пользователь решает запомнить,
IP-адрес отправителя записывается в список разрешений или запретов, так что
будущие запросы с того же адреса пропускают диалог. Вся эта логика реализована
в DlnaReceiverViewModel, поверх проводного протокола — сам SOAP-обработчик
всегда возвращает 200 OK независимо от решения о доверии (согласно спецификации
UPnP, транспортный вызов выполнен успешно; будет ли медиа фактически
воспроизведено — это отдельное локальное решение).
Разделение платформ: commonMain для оркестрации, androidMain для сокетов
Следуя тому же шаблону, что и другие сетевые функции PlainApp, только сырой
байтовый ввод/вывод через сокеты является платформозависимым. DlnaServerSocket
и DlnaSsdpSocket — это интерфейсы expect; реализации actual на Android
напрямую оборачивают java.net.ServerSocket и java.net.MulticastSocket.
Всё остальное — выбор порта, ритм SSDP alive/byebye, HTTP-маршрутизация,
SOAP-парсинг, DIDL-Lite метаданные и все четыре перехода состояния DlnaCommand —
находится в commonMain и может быть протестировано модульно на JVM без
Android-устройства.
iOS получает SOAP-управление на стороне отправителя (это обычный HTTP-клиент, не требует реализации сокетов), но получатель намеренно реализован как пустышка:
actual fun startDlnaRenderer() {}
iOS не предоставляет достаточно надёжного способа запустить фоновый UDP-мультикаст
слушатель, чтобы быть достойным MediaRenderer, поэтому "Wireless Cast" (приём)
доступен только на Android; "Cast to TV" (отправка) работает на обеих платформах.
Заметки по инженерии на чистом Kotlin
Несколько деталей реализации существуют специально для того, чтобы избежать платформенных зависимостей, которые нарушили бы совместное использование Kotlin Multiplatform:
- Генерация UUID v4.
DlnaReceiverEngine.randomUuid()вручную реализует UUID v4 по RFC 4122 черезRandom.nextBytes(16)вместоjava.util.UUID.randomUUID(), чтобы генератор идентификаторов устройств был идентичным на всех платформах. - Процентное декодирование. Приватный метод
percentDecode()вDlnaSoapHandlerзаменяетjava.net.URLDecoder.decode()для названий, поступающих в URL-кодированном виде в медиа-URI. - Чтение HTTP-тела на уровне байтов.
Content-Length— это количество байтов, а не символов.AndroidDlnaClientConnection.readHttpRequest()читает тело черезreadBodyBytes(bis, contentLength)напрямую из сырогоBufferedInputStream, никогда не используяBufferedReader/CharArray— название, содержащее многобайтовые символы UTF-8 (например, китайские иероглифы, по 3 байта каждый), иначе недочитало бы тело и зависло в ожидании байтов, которые уже пришли, тихо ломаяSetAVTransportURIдля не-ASCII названий. - Парсинг базового URL без
java.net.URL.DlnaDevice.getBaseUrl()извлекает схему/хост/порт из заголовкаLOCATIONс помощью простой нарезки строк (substringAfter("://"),substringBefore('/')) вместо созданияjava.net.URL.
Обзор паттернов проектирования
| Паттерн | Где | Почему |
|---|---|---|
| Общий протокол, разделённый ввод/вывод | DlnaSoap/DlnaXmlTemplates в commonMain, сокеты в androidMain | Формат проводного протокола определён один раз, платформа только подаёт байты на вход/выход |
| Отложенный → Проверка правил → Продвижение | rawPendingCastRequest → pendingCastRequest | Разделяет "запрос поступил" и "запрос требует решения человека" |
| Разделение через очередь команд | Channel<DlnaCommand> | Корутина HTTP-обработчика никогда не касается ExoPlayer/состояния UI напрямую |
| Воспроизведение отложенной команды | pendingPlayQueued | Play, опередивший одобрение SetAVTransportURI, не теряется |
| Намеренно выбранный код статуса | respondDlnaFile всегда 206 | Соответствует ожиданиям реальных прошивок телевизоров, а не только минимальному 200 по спецификации |
| Узкий фильтр дубликатов | !xml.contains("AVTransportURIMetaData") | Различает два колбэка STOPPED, которые некоторые реендеры отправляют, без общего механизма дедупликации |
| Немедленный Byebye | stop() отправляет ssdp:byebye перед отменой | Избегает устаревшей 30-минутной записи в SSDP-кэше после того, как пользователь отключил функцию |
| Открыт для неизвестных, закрыт по умолчанию | список разрешений/запретов + диалог | Ни автоматическое доверие, ни автоматическая блокировка первого отправителя — человек решает один раз |
Дополнительные материалы
- UPnP Device Architecture — базовая спецификация SSDP/GENA/SOAP.
- О функции низколатентного зеркалирования экрана на основе WebSocket (другой путь кастинга, не связанный с DLNA), см. Screen Mirror.