DLNA (xây dựng trên UPnP AV) là giao thức đằng sau nút "Cast to TV" trên các TV thông minh. Nó hoạt động hoàn toàn trên mạng nội bộ, không cần tài khoản đám mây và không cần bước ghép đôi. PlainApp triển khai cả hai hướng của nó: nó có thể đẩy video, bài hát hoặc ảnh cục bộ lên bất kỳ TV thông minh tương thích DLNA nào, và nó có thể biến chính chiếc điện thoại thành một MediaRenderer để một ứng dụng điều khiển TV, VLC hoặc một PlainApp khác có thể cast tới nó.
Bài viết này đề cập đến giao thức đường dây (SSDP + SOAP + DIDL-Lite), máy chủ HTTP cục bộ phát trực tiếp media với các header mà TV thực sự yêu cầu, cơ chế đăng ký sự kiện GENA cho trạng thái phát lại, và mô hình tin cậy dựa trên IP người gửi giúp giữ cho một giao thức không xác thực từ thập niên 1990 vận hành an toàn trên điện thoại hiện đại.
Mục lục
- Mục lục
- Kiến trúc tổng quan
- SSDP Discovery: Tìm thiết bị không cần máy chủ
- Điều khiển AVTransport: Giao thức SOAP
- DIDL-Lite Metadata và điểm đặc biệt về Double-Escaping
- Phân phối Media cho TV: Range Request và DLNA Headers
- GENA Events: Callback trạng thái phát lại
- Chế độ Receiver: Trở thành UPnP MediaRenderer
- Bảo mật: Tin cậy người gửi qua Danh sách Allow/Deny
- Phân tách nền tảng: commonMain điều phối, androidMain Sockets
- Ghi chú Kỹ thuật Kotlin thuần
- Tổng hợp các Mẫu thiết kế
- Đọc thêm
Kiến trúc tổng quan
DLNA/UPnP AV không có máy chủ trung tâm và không có thành phần đám mây — mọi thứ diễn ra qua UDP multicast (discovery) và HTTP (điều khiển + media) trên mạng nội bộ. PlainApp triển khai hai vai trò độc lập, tình cờ chia sẻ cùng một mã giao thức commonMain:
| Vai trò | Chức năng | Các lớp chính |
|---|---|---|
| Bộ gửi ("Cast to TV") | Quét các bộ nhận, yêu cầu một bộ nhận tải một URL, điều khiển phát lại | DlnaDeviceScanner, DlnaTransportController, CastPlayer |
| Bộ nhận ("Wireless Cast") | Tự quảng bá là một MediaRenderer, chấp nhận điều khiển từ bất kỳ bộ điều khiển UPnP nào | DlnaReceiverEngine, DlnaHttpRouter, DlnaSoapHandler, DlnaReceiverViewModel |
Cả hai vai trò đều tái sử dụng DlnaSoap (trình xây dựng SOAP envelope) và DlnaDevice (mô hình thiết bị UPnP) từ features/dlna/common/. Đặc tả DLNA không có bất kỳ hình thức xác thực nào — bất kỳ thiết bị nào trên LAN biết URL điều khiển của một bộ nhận đều có thể gửi lệnh cho nó. Điều này định hình hầu như mọi quyết định thiết kế được mô tả dưới đây, đặc biệt là mô hình tin cậy của bộ nhận.
SSDP Discovery: Tìm thiết bị không cần máy chủ
Discovery sử dụng SSDP (Simple Service Discovery Protocol), một lớp mỏng trên UDP multicast tới 239.255.255.250:1900. Không có máy chủ thư mục — các thiết bị tự công bố và trả lời trực tiếp các truy vấn tìm kiếm.
Với vai trò bộ gửi, DlnaDeviceScanner phát một gói tin M-SEARCH nhắm tới urn:schemas-upnp-org:service:AVTransport:1 và thu thập các phản hồi unicast 200 OK, mỗi phản hồi mang một header LOCATION trỏ tới description.xml của bộ nhận. Bộ quét loại bỏ trùng lặp theo hostAddress — nó không tự phân tích XML thiết bị; việc đó được để cho CastViewModel.searchAsync(), nó tải LOCATION, gọi device.update(xml), và chỉ hiển thị các thiết bị mà device.isAVTransport() trả về 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)
}
}
Với vai trò bộ nhận, DlnaReceiverEngine.runSsdpLoop() gửi ba gói tin NOTIFY ssdp:alive khi khởi động (thiết bị gốc, loại thiết bị MediaRenderer:1, loại dịch vụ AVTransport:1), tái công bố mỗi 30 giây (CACHE-CONTROL: max-age=1800), và trả lời các yêu cầu M-SEARCH đến bằng phản hồi unicast. Khi stop(), nó gửi ssdp:byebye ngay lập tức thay vì đợi bộ nhớ đệm 30 phút hết hạn — để một ứng dụng điều khiển TV ngừng liệt kê PlainApp ngay khi "Wireless Cast" bị tắt.
Dự phòng cổng
Máy chủ HTTP của bộ nhận thử cổng 7878 trước, sau đó 7879, rồi 7880, ưu tiên cổng đã dùng thành công lần trước:
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
}
Nếu cả ba cổng đều bị chiếm (hiếm gặp, nhưng có thể xảy ra khi có ứng dụng DLNA khác đang chạy), startError được đặt và hiển thị trên giao diện thay vì lặng lẽ thất bại.
Điều khiển AVTransport: Giao thức SOAP
Khi đã tìm thấy bộ nhận, việc điều khiển phát lại được thực hiện qua UPnP AVTransport, một dịch vụ SOAP qua HTTP. Mỗi hành động là một POST tới URL điều khiển của bộ nhận với header SOAPAction và phần thân XML được bọc trong một SOAP envelope.
DlnaTransportController xây dựng mỗi yêu cầu bằng một hàm trợ giúp dùng chung:
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 đặt SOAPAction: "<serviceType>#<action>" và gửi DlnaSoap.requestEnvelope(soapBody) — cùng các hằng số envelope được phía bộ nhận sử dụng để xây dựng phản hồi, nhờ đó định dạng đường dây chỉ cần được định nghĩa một lần trong commonMain.
DlnaHttpRouter.handleSoap() của bộ nhận phản chiếu điều này ở đầu kia: nó đọc header soapaction, trích xuất tên hành động sau dấu #, và phân phối — SetAVTransportURI, Play, Pause, Stop, Seek, GetTransportInfo, GetPositionInfo, GetMediaInfo, GetDeviceCapabilities. RenderingControl (âm lượng) được làm giả với giá trị tĩnh 100 — PlainApp không công khai âm lượng thiết bị qua UPnP.
DIDL-Lite Metadata và điểm đặc biệt về Double-Escaping
SetAVTransportURI mang hai tham số: CurrentURI (URL media) và CurrentURIMetaData — một đoạn XML DIDL-Lite mô tả tiêu đề, lớp media, và ảnh bìa album, được nhúng dưới dạng chuỗi đã thoát XML bên trong phần thân SOAP bên ngoài:
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(">", ">")
}
Đoạn DIDL-Lite XML được thoát hai lần: một lần cho chính văn bản tiêu đề (để một bài hát tên Fire & Ice không phá vỡ các thẻ DIDL-Lite), và một lần cho toàn bộ tài liệu DIDL-Lite (để các ký tự </> của nó không phá vỡ SOAP envelope bên ngoài mà nó được nhúng vào dưới dạng văn bản). Đây là một điểm đặc biệt nổi tiếng của UPnP, không phải lỗi — CurrentURIMetaData được định nghĩa là nội dung chuỗi, không phải là các phần tử XML lồng nhau.
Ở phía bộ nhận, DlnaSoapHandler thực hiện ngược lại: parseSoapAction giải thoát phần thân SOAP một lần để lấy văn bản DIDL-Lite, sau đó extractTitleFromDidlMeta/extractMediaTypeFromDidlMeta/extractAlbumArtUriFromDidlMeta mỗi hàm thực hiện một lượt giải thoát thực thể và trích xuất thẻ trên chuỗi bên trong đó:
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
}
}
Nếu <upnp:class> bị thiếu (một số bộ gửi bỏ qua nó), cleanMediaTitle() sẽ dự phòng vào phần mở rộng tệp của chính URI — việc phát hiện loại media không bao giờ thất bại cứng, nó chỉ giảm xuống UNKNOWN, và UNKNOWN sẽ được dẫn đến trình phát video như một mặc định an toàn.
Phân phối Media cho TV: Range Request và DLNA Headers
Một lời gọi SetAVTransportURI chỉ cho bộ nhận biết lấy media từ đâu — các byte thực tế được phục vụ bởi máy chủ HTTP cục bộ của PlainApp, tại /media/{id}.
UrlHelper.getMediaHttpUrl(path) đăng ký đường dẫn thực (có thể là URI content://, URL từ xa, hoặc đường dẫn tệp thông thường) dưới một id ngắn và trả về http://<địa-chỉ-thiết-bị>:<cổng>/media/<id>.<phần-mở-rộng>. Route sau đó rẽ nhánh dựa trên loại nguồn thực tế:
when {
path.isUrl() -> call.proxyUrl(path) // URL từ xa: chuyển tiếp luồng phản hồi upstream
isContentUri(path) -> call.respondStream { sink -> streamContentUri(path, sink) }
path.isImageFast() -> call.respondFile(path) // ảnh: phục vụ tĩnh đơn giản
else -> call.respondDlnaFile(path) // âm thanh/video: phục vụ có nhận thức DLNA
}
respondDlnaFile là trường hợp thú vị — nhiều TV thông minh và bộ nhận DLNA từ chối phát một luồng trừ khi nó trông giống như phản hồi từ một máy chủ media DLNA đúng chuẩn:
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) // một số hệ điều hành TV chỉ chấp nhận 206
}
applicationCall.respond(LocalFileContent(file))
return true
}
Lưu ý rằng trạng thái luôn là 206 Partial Content, không phải 200 OK — một số phần mềm TV coi phản hồi 200 thông thường là "không thể tua" và từ chối phát, ngay cả đối với GET toàn bộ tệp. Chỉ một lựa chọn mã trạng thái này tạo nên sự khác biệt giữa "phát tốt" và "TV quay vòng vô tận" trên nhiều thiết bị thực tế.
Cùng một route cũng phục vụ ảnh bìa album cho các mục âm thanh đang cast: UrlHelper.getAlbumArtHttpUrl() ánh xạ URI content://media/.../albumart/<id> vào cùng đường dẫn /media/{id}, nhờ đó streaming content:// và phục vụ tệp DLNA chia sẻ một đường dẫn mã bất kể "media" được đề cập là bài hát hay ảnh bìa của nó.
GENA Events: Callback trạng thái phát lại
Sau khi bắt đầu phát lại, bộ gửi đăng ký dịch vụ AVTransport eventing (GENA — General Event Notification Architecture) của bộ nhận để biết được các thay đổi trạng thái mà không cần 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 là các phương thức HTTP tùy chỉnh (không nằm trong bộ động từ tiêu chuẩn), được xử lý qua trình xây dựng yêu cầu HttpMethod("SUBSCRIBE") chung của Ktor. Sau đó, bộ nhận gửi NOTIFY tới callbackUrl — route /callback/cast của PlainApp — bất cứ khi nào trạng thái vận chuyển, vị trí hoặc thời lượng thay đổi:
if (xml.contains("TransportState val=\"STOPPED\"") && !xml.contains("AVTransportURIMetaData")) {
// chuyển đến mục tiếp theo trong danh sách phát
} else if (xml.contains("TransportState val=\"PLAYING\"")) {
CastPlayer.isPlaying.value = true
}
Bảo vệ khỏi callback trùng lặp
Một số bộ nhận gửi hai callback NOTIFY liên tiếp cho cùng một chuyển đổi STOPPED — cái thứ hai tình cờ mang AVTransportURIMetaData trong khi cái đầu tiên thì không. Nếu tiến danh sách phát ở cả hai, mỗi khi phát lại dừng tự nhiên sẽ bỏ qua một track. Kiểm tra !xml.contains("AVTransportURIMetaData") là một bộ lọc có chủ đích, hẹp: chỉ thông báo STOPPED đầu tiên (không có metadata) mới kích hoạt tự động tiến. Một tác vụ startPositionUpdater() cũng polling GetPositionInfo mỗi giây như một phương án dự phòng, vì việc xác nhận SUBSCRIBE của PlainApp không đẩy sự kiện trở lại các bộ điều khiển khác — chỉ phía bộ gửi mới tiêu thụ callback GENA từ TV.
Chế độ Receiver: Trở thành UPnP MediaRenderer
Đảo ngược hướng: bất kỳ bộ điều khiển DLNA nào (ứng dụng điều khiển TV, VLC, một PlainApp khác) đều có thể đẩy media tới chính chiếc điện thoại. DlnaReceiverEngine mở cùng loại máy chủ HTTP + SSDP như đã mô tả ở trên, nhưng lần này là với vai trò MediaRenderer bị điều khiển thay vì là bộ điều khiển.
DlnaHttpRouter.route() phục vụ description.xml (được xây dựng bởi DlnaXmlTemplates.deviceDescription(), liệt kê một dịch vụ AVTransport và một dịch vụ RenderingControl giả) và phân phối các hành động SOAP tới DlnaSoapHandler. Một lời gọi SetAVTransportURI không phát ngay lập tức — nó lưu trữ một PendingCastRequest và chờ:
if (uri.isNotEmpty()) {
DlnaRendererState.rawPendingCastRequest.value =
PendingCastRequest(senderIp, senderName, uri, title, mediaType, albumArtUri)
DlnaRendererState.pendingPlayQueued.value = false
}
Một Play đến trước khi yêu cầu đang chờ được giải quyết cũng không bắt đầu phát lại — nó đặt pendingPlayQueued = true để lệnh sẽ tự động phát lại khi yêu cầu cast được chấp nhận, thay vì bị mất một cách lặng lẽ:
val hasPending = DlnaRendererState.rawPendingCastRequest.value != null ||
DlnaRendererState.pendingCastRequest.value != null
if (hasPending) {
DlnaRendererState.pendingPlayQueued.value = true
} else {
DlnaRendererState.commandChannel.trySend(DlnaCommand.Play)
}
Các lệnh được chấp nhận chảy qua một Channel<DlnaCommand> duy nhất mà DlnaReceiverViewModel tiêu thụ, tách biệt coroutine xử lý socket thô khỏi trạng thái UI/trình phát — trình xử lý HTTP không bao giờ chạm trực tiếp vào ExoPlayer. Phát lại sau đó được dẫn đến một trong ba composable toàn màn hình theo DlnaMediaType: DlnaReceiverAudioPlayerContent (nền gradient, ảnh bìa album, thanh tua), trình xem ảnh, hoặc DlnaReceiverVideoPlayerContent (ExoPlayer).
Bảo mật: Tin cậy người gửi qua Danh sách Allow/Deny
DLNA không có xác thực theo thiết kế — bất kỳ thiết bị nào trên LAN đều có thể gửi SetAVTransportURI tới một bộ nhận. Biến một chiếc điện thoại cá nhân thành một MediaRenderer không xác thực sẽ cho phép bất kỳ ai trên cùng Wi-Fi (mạng văn phòng dùng chung, nhà bạn bè, hoặc mạng khách có chủ đích xấu) đẩy các URL media tùy ý tới nó. PlainApp đóng lỗ hổng này bằng một danh sách tin cậy theo IP người gửi, kiểm soát mọi yêu cầu cast đến:
DlnaRendererState.rawPendingCastRequest.filterNotNull().collect { pending ->
val allowed = DlnaAllowedSendersPreference.getAsync()
val denied = DlnaDeniedSendersPreference.getAsync()
when {
DlnaAllowedSendersPreference.containsIp(allowed, pending.senderIp) -> {
// Tự động chấp nhận: gửi lệnh trực tiếp, không hiển thị hộp thoại
}
DlnaDeniedSendersPreference.containsIp(denied, pending.senderIp) -> {
// Tự động từ chối: lặng lẽ loại bỏ
}
else -> {
// Người gửi không xác định: đưa lên trạng thái UI để người dùng quyết định
DlnaRendererState.pendingCastRequest.value = pending
}
}
}
Yêu cầu cast từ người gửi không xác định hiển thị một hộp thoại xác nhận với tùy chọn "ghi nhớ lựa chọn này"; chọn ghi nhớ sẽ ghi IP của người gửi vào tùy chọn allow hoặc deny để các yêu cầu sau từ cùng địa chỉ bỏ qua hộp thoại. Điều này được thực thi hoàn toàn trong DlnaReceiverViewModel, phía trên giao thức đường dây — bản thân trình xử lý SOAP luôn trả về 200 OK bất kể quyết định tin cậy (theo đặc tả UPnP, lời gọi vận chuyển đã thành công; liệu media có thực sự phát hay không là một quyết định cục bộ riêng biệt).
Phân tách nền tảng: commonMain điều phối, androidMain Sockets
Theo cùng mô hình với các tính năng mạng khác của PlainApp, chỉ có I/O socket ở cấp byte thô là phụ thuộc nền tảng. DlnaServerSocket và DlnaSsdpSocket là các interface expect; triển khai actual trên Android bao bọc trực tiếp java.net.ServerSocket và java.net.MulticastSocket. Mọi thứ khác — chọn cổng, nhịp độ alive/byebye SSDP, định tuyến HTTP, phân tích SOAP, xử lý metadata DIDL-Lite, và tất cả bốn chuyển đổi trạng thái DlnaCommand — đều nằm trong commonMain và có thể kiểm thử đơn vị trên JVM mà không cần thiết bị Android.
iOS có điều khiển SOAP phía bộ gửi (nó là một HTTP client đơn giản, không cần triển khai socket), nhưng bộ nhận là một no-op có chủ đích:
actual fun startDlnaRenderer() {}
iOS không cung cấp cách chạy trình lắng nghe UDP multicast nền đủ đáng tin cậy để trở thành một MediaRenderer tốt, vì vậy "Wireless Cast" (nhận) chỉ có trên Android; "Cast to TV" (gửi) hoạt động trên cả hai nền tảng.
Ghi chú Kỹ thuật Kotlin thuần
Một vài chi tiết triển khai tồn tại đặc biệt để tránh các phụ thuộc nền tảng có thể phá vỡ khả năng chia sẻ của Kotlin Multiplatform:
- Sinh UUID v4.
DlnaReceiverEngine.randomUuid()tự tạo một UUID v4 theo RFC 4122 từRandom.nextBytes(16)thay vìjava.util.UUID.randomUUID(), để trình sinh định danh thiết bị giống hệt nhau trên mọi nền tảng. - Giải mã percent. Hàm riêng tư
percentDecode()củaDlnaSoapHandlerthay thếjava.net.URLDecoder.decode()cho các tiêu đề đến dưới dạng mã hóa URL trong một URI media. - Đọc phần thân HTTP ở cấp byte.
Content-Lengthlà một đếm byte, không phải đếm ký tự.AndroidDlnaClientConnection.readHttpRequest()đọc phần thân quareadBodyBytes(bis, contentLength)từBufferedInputStreamthô, không bao giờ dùngBufferedReader/CharArray— một tiêu đề chứa UTF-8 đa byte (ví dụ ký tự Trung Quốc, mỗi ký tự 3 byte) nếu không sẽ đọc thiếu phần thân và bị treo chờ các byte đã đến, lặng lẽ phá vỡSetAVTransportURIcho các tiêu đề không phải ASCII. - Phân tích Base URL không dùng
java.net.URL.DlnaDevice.getBaseUrl()trích xuất scheme/host/port từ headerLOCATIONbằng cách cắt chuỗi đơn giản (substringAfter("://"),substringBefore('/')) thay vì tạo một đối tượngjava.net.URL.
Tổng hợp các Mẫu thiết kế
| Mẫu | Vị trí | Lý do |
|---|---|---|
| Giao thức dùng chung, I/O phân tách | DlnaSoap/DlnaXmlTemplates trong commonMain, socket trong androidMain | Định dạng đường dây chỉ định nghĩa một lần, nền tảng chỉ cung cấp byte vào/ra |
| Đang chờ → Kiểm tra luật → Nâng cấp | rawPendingCastRequest → pendingCastRequest | Tách biệt "một yêu cầu đã đến" khỏi "một yêu cầu cần quyết định của con người" |
| Giải ghép nối hàng đợi lệnh | Channel<DlnaCommand> | Coroutine xử lý HTTP không bao giờ chạm trực tiếp vào trạng thái ExoPlayer/UI |
| Phát lại lệnh đã xếp hàng | pendingPlayQueued | Một Play đến trước khi SetAVTransportURI được phê duyệt sẽ không bị mất |
| Mã trạng thái có chủ đích | respondDlnaFile luôn trả về 206 | Khớp với những gì phần mềm TV thực tế mong đợi, không chỉ mức tối thiểu 200 của đặc tả |
| Bộ lọc trùng lặp hẹp | !xml.contains("AVTransportURIMetaData") | Phân biệt hai callback STOPPED mà một số bộ nhận gửi, không dùng cơ chế khử trùng lặp tổng quát |
| Byebye ngay lập tức | stop() gửi ssdp:byebye trước khi hủy | Tránh một mục cache SSDP hết hạn sau 30 phút khi người dùng tắt tính năng |
| Mở với không xác định, đóng theo mặc định | tùy chọn allow/deny + hộp thoại | Không tự động tin cậy cũng không tự động chặn người gửi lần đầu — con người quyết định một lần |
Đọc thêm
- UPnP Device Architecture — đặc tả nền tảng SSDP/GENA/SOAP.
- Về tính năng phản chiếu màn hình độ trễ thấp dựa trên WebSocket (một đường dẫn cast khác, không phải DLNA), xem Screen Mirror.