DLNA(基于 UPnP AV 构建)是电视上"投屏"按钮背后的协议。它完全运行在局域网内,不需要云账号,也无需任何配对步骤。PlainApp 同时实现了它的两个方向:既可以把本地的视频、歌曲或照片推送到任意支持 DLNA 的智能电视,也可以把手机本身变成一台 MediaRenderer,让电视遥控 App、VLC 或另一台 PlainApp 反过来向它投屏。
本文覆盖底层协议(SSDP + SOAP + DIDL-Lite)、真正满足电视端要求的本地 HTTP 媒体服务器、用于播放状态回调的 GENA 事件订阅,以及让这个诞生于上世纪 90 年代、没有任何鉴权的协议在现代手机上安全运行的发送方信任模型。
目录
- 总体架构
- SSDP 发现:无需服务器找到设备
- AVTransport 控制:SOAP 协议
- DIDL-Lite 元数据与二次转义细节
- 向电视提供媒体:Range 请求与 DLNA 专用头
- GENA 事件:播放状态回调
- 接收模式:变身 UPnP MediaRenderer
- 安全:基于允许/拒绝名单的发送方信任
- 平台拆分:commonMain 编排,androidMain 套接字
- 纯 Kotlin 工程细节
- 设计模式回顾
- 延伸阅读
总体架构
DLNA/UPnP AV 没有中心服务器,也没有云端组件——一切都发生在局域网的 UDP 组播(发现)与 HTTP(控制 + 媒体)之上。PlainApp 实现了两个互相独立、但共享同一份 commonMain 协议代码的角色:
| 角色 | 作用 | 关键类 |
|---|---|---|
| 发送端("投屏到电视") | 扫描 Renderer,让其拉取一个 URL,控制播放 | DlnaDeviceScanner、DlnaTransportController、CastPlayer |
| 接收端("无线投屏") | 把自己广播为一台 MediaRenderer,接受任意 UPnP 控制点的控制 | DlnaReceiverEngine、DlnaHttpRouter、DlnaSoapHandler、DlnaReceiverViewModel |
两个角色都复用了 features/dlna/common/ 下的 DlnaSoap(SOAP 信封构造器)与 DlnaDevice(UPnP 设备模型)。DLNA 规范本身不含任何鉴权机制——局域网内任何知道 Renderer 控制 URL 的设备都能向它发送控制命令。这一点决定了下文几乎所有的设计决策,尤其是接收端的信任模型。
SSDP 发现:无需服务器找到设备
发现阶段使用 SSDP(Simple Service Discovery Protocol),它只是在 UDP 组播地址 239.255.255.250:1900 上薄薄的一层协议。没有目录服务器——设备直接广播自身并应答搜索请求。
作为发送端,DlnaDeviceScanner 广播一个针对 urn:schemas-upnp-org:service:AVTransport:1 的 M-SEARCH 数据报,并收集单播的 200 OK 回复,每个回复都带有指向 Renderer description.xml 的 LOCATION 头。扫描器仅按 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 分钟缓存自然过期——这样用户一关闭"无线投屏",电视遥控 App 就会立刻不再列出 PlainApp。
端口回退
接收端的 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 并在 UI 中展示出来,而不是静默失败。
AVTransport 控制:SOAP 协议
找到 Renderer 之后,播放控制通过 UPnP AVTransport 完成——一个基于 HTTP 的 SOAP 服务。每个动作都是向 Renderer 控制 URL 发起的一次 POST,带有 SOAPAction 头和包在 SOAP 信封里的 XML 请求体。
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>",并 POST 出 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——一段描述标题、媒体类型和专辑封面的 DIDL-Lite XML 片段,以 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 惯例,不是 bug——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,而 UNKNOWN 会安全地默认路由到视频播放器。
向电视提供媒体:Range 请求与 DLNA 专用头
SetAVTransportURI 只是告诉 Renderer 从哪里拉取媒体——真正的字节数据由 PlainApp 自己的本地 HTTP 服务器在 /media/{id} 上提供。
UrlHelper.getMediaHttpUrl(path) 把真实路径(可能是 content:// URI、远程 URL 或普通文件路径)注册为一个短 id,并返回 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 Renderer 除非响应看起来像一个标准的 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() 把 content://media/.../albumart/<id> 这样的 URI 映射到同样的 /media/{id} 路径上,所以无论"媒体"指的是歌曲本身还是它的封面图,content:// 流式传输与 DLNA 文件服务都走同一条代码路径。
GENA 事件:播放状态回调
开始播放之后,发送端会订阅 Renderer 的 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 方法(不在标准动词集合里),通过 Ktor 的通用 HttpMethod("SUBSCRIBE") 请求构造器来处理。之后每当传输状态、播放位置或时长发生变化,Renderer 就会向 callbackUrl——也就是 PlainApp 自己的 /callback/cast 路由——发送 NOTIFY:
if (xml.contains("TransportState val=\"STOPPED\"") && !xml.contains("AVTransportURIMetaData")) {
// 前进到播放列表中的下一项
} else if (xml.contains("TransportState val=\"PLAYING\"")) {
CastPlayer.isPlaying.value = true
}
重复回调的防护
部分 Renderer 会针对同一次 STOPPED 状态转换连续发送两次 NOTIFY 回调——第二次恰好携带 AVTransportURIMetaData,第一次则没有。如果两次都触发"前进到下一曲",每次播放自然停止时都会多跳过一首歌。!xml.contains("AVTransportURIMetaData") 这个判断是刻意设计的、范围很窄的过滤条件:只有第一条(不带元数据的)STOPPED 通知才会触发自动前进。startPositionUpdater() 作业还会每秒轮询一次 GetPositionInfo 作为兜底方案,因为 PlainApp 自身对 SUBSCRIBE 的确认并不会向其他控制点推送事件——只有发送端这一侧会消费来自电视的 GENA 回调。
接收模式:变身 UPnP MediaRenderer
反过来看:任何 DLNA 控制点(电视遥控 App、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。播放随后会按 DlnaMediaType 路由到三个全屏 Composable 之一:DlnaReceiverAudioPlayerContent(渐变背景、专辑封面、进度条)、图片查看器,或 DlnaReceiverVideoPlayerContent(ExoPlayer)。
安全:基于允许/拒绝名单的发送方信任
DLNA 在设计上没有任何鉴权——局域网内的任何设备都能向一台 Renderer 发送 SetAVTransportURI。如果把一部私人手机变成一台无鉴权的 MediaRenderer,那么同一 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 其他网络功能的同一套模式,只有最底层字节级别的套接字 I/O 是平台相关的。DlnaServerSocket 和 DlnaSsdpSocket 是 expect 接口;Android 端的 actual 实现直接封装 java.net.ServerSocket 与 java.net.MulticastSocket。其余的一切——端口选择、SSDP alive/byebye 的节奏、HTTP 路由、SOAP 解析、DIDL-Lite 元数据处理,以及全部四种 DlnaCommand 的状态转换——都放在 commonMain 里,可以在没有 Android 设备的情况下直接在 JVM 上做单元测试。
iOS 拥有发送端的 SOAP 控制能力(它只是一个普通 HTTP 客户端,不需要实现套接字),但接收端被有意设计为空实现:
actual fun startDlnaRenderer() {}
iOS 没有一种足够可靠的方式在后台持续运行 UDP 组播监听,无法胜任一个合格的 MediaRenderer,因此"无线投屏"(接收)功能仅限 Android;"投屏到电视"(发送)在两个平台上都可用。
纯 Kotlin 工程细节
有几处实现细节专门是为了避免会破坏 Kotlin Multiplatform 共享的平台依赖:
- UUID v4 生成。
DlnaReceiverEngine.randomUuid()用Random.nextBytes(16)手写实现了一个符合 RFC 4122 v4 规范的 UUID,而不是使用java.util.UUID.randomUUID(),这样设备身份生成器在每个平台上都完全一致。 - 百分号解码。
DlnaSoapHandler里的私有函数percentDecode()替代了java.net.URLDecoder.decode(),用于处理媒体 URI 中以 URL 编码形式出现的标题。 - 字节级别的 HTTP 请求体读取。
Content-Length是一个字节计数,而不是字符计数。AndroidDlnaClientConnection.readHttpRequest()通过readBodyBytes(bis, contentLength)直接对原始BufferedInputStream读取请求体,绝不使用BufferedReader/CharArray——一个包含多字节 UTF-8 字符(例如中文,每个字符 3 字节)的标题,否则会导致请求体读取不足,卡在等待其实已经到达的字节上,从而在标题含非 ASCII 字符时静默地破坏SetAVTransportURI。 - 不依赖
java.net.URL的 Base URL 解析。DlnaDevice.getBaseUrl()用纯字符串切片(substringAfter("://")、substringBefore('/'))从LOCATION头里提取协议/主机/端口,而不是构造一个java.net.URL。
设计模式回顾
| 模式 | 位置 | 原因 |
|---|---|---|
| 协议共享,I/O 拆分 | DlnaSoap/DlnaXmlTemplates 在 commonMain,套接字在 androidMain | 协议格式只定义一次,平台层只负责收发字节 |
| 待定 → 规则检查 → 提升 | rawPendingCastRequest → pendingCastRequest | 把"请求到达了"与"请求需要人来决定"这两件事分开 |
| 命令队列解耦 | Channel<DlnaCommand> | HTTP 处理协程永远不直接触碰 ExoPlayer/UI 状态 |
| 排队命令重放 | pendingPlayQueued | 抢在 SetAVTransportURI 裁决完成之前到达的 Play 不会被丢弃 |
| 刻意选择的状态码 | respondDlnaFile 始终返回 206 | 匹配真实电视固件的预期,而不只是满足规范的最低要求 200 |
| 窄范围的重复过滤 | !xml.contains("AVTransportURIMetaData") | 在没有通用去重机制的情况下,区分某些 Renderer 发送的两次 STOPPED 回调 |
| 立即发送 byebye | stop() 在取消前发送 ssdp:byebye | 避免用户关闭功能后残留一条长达 30 分钟的过期 SSDP 缓存条目 |
| 未知即开放确认,默认关闭 | 允许/拒绝名单 + 对话框 | 既不自动信任也不自动拦截首次出现的发送方——由人来决定一次 |
延伸阅读
- UPnP Device Architecture —— 底层 SSDP/GENA/SOAP 规范。
- 关于基于 WebSocket 的低延迟屏幕镜像功能(另一条与 DLNA 无关的投屏路径),参见 Screen Mirror。