Skip to content

Commit 91250af

Browse files
yinnhoclaude
andcommitted
Add unit tests and extract testable pure helpers
Move pure logic (DLNA time conversion, SOAP XML extraction, DIDL-Lite metadata construction, MIME mapping, SSDP header parsing) out of DlnaMediaController, LocalFileServer and SsdpDeviceDiscovery into internal util objects, and cover them with a 69-test JUnit 5 suite. No behavior change; the CI test step now actually executes tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 74abc93 commit 91250af

14 files changed

Lines changed: 588 additions & 204 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to the UPnPCast library will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### ✨ Added
11+
- **Unit tests**: the project's first test suite (69 tests) covers DLNA time parsing/formatting, SOAP XML value extraction (position/volume/mute), DIDL-Lite metadata construction (MIME/class detection, XML escaping, subtitle resources, verbatim override), MIME type mapping and SSDP header parsing — making the CI `test` step meaningful
12+
13+
### 🔧 Changed
14+
- Pure logic extracted from `DlnaMediaController`, `LocalFileServer` and `SsdpDeviceDiscovery` into internal helpers (`UpnpTime`, `SoapXml`, `MetadataBuilder`, `MimeTypes`, `SsdpHeaders`) with no behavior change
15+
816
## [1.2.0] - 2026-09-02
917

1018
### ✨ Added

app/src/main/java/com/yinnho/upnpcast/internal/discovery/SsdpDeviceDiscovery.kt

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import java.util.concurrent.Executor
1313
import java.util.concurrent.ExecutorService
1414
import java.util.concurrent.Executors
1515
import java.util.concurrent.atomic.AtomicBoolean
16+
import com.yinnho.upnpcast.internal.util.SsdpHeaders
1617

1718
/**
1819
* SSDP device discovery service
@@ -224,9 +225,9 @@ internal class SsdpDeviceDiscovery(
224225
*/
225226
private fun processNotify(message: String, fromAddress: InetAddress) {
226227
try {
227-
val nts = extractHeader(message, "NTS")
228+
val nts = SsdpHeaders.extract(message, "NTS")
228229
if (nts == "ssdp:alive") {
229-
val headers = parseSsdpHeaders(message)
230+
val headers = SsdpHeaders.parse(message)
230231
val location = headers["location"]
231232

232233
if (location != null) {
@@ -241,9 +242,9 @@ internal class SsdpDeviceDiscovery(
241242
processSsdpResponse(message, fromAddress)
242243
}
243244
} else if (nts == "ssdp:byebye") {
244-
val usn = extractHeader(message, "USN")
245+
val usn = SsdpHeaders.extract(message, "USN")
245246
if (usn != null) {
246-
val headers = parseSsdpHeaders(message)
247+
val headers = SsdpHeaders.parse(message)
247248
val location = headers["location"]
248249
if (location != null) {
249250
synchronized(processedLock) {
@@ -258,36 +259,12 @@ internal class SsdpDeviceDiscovery(
258259
}
259260
}
260261

261-
/**
262-
* Extract HTTP header information
263-
*/
264-
private fun extractHeader(message: String, headerName: String): String? {
265-
val regex = "$headerName:\\s*(.+)".toRegex(RegexOption.IGNORE_CASE)
266-
return regex.find(message)?.groupValues?.get(1)?.trim()
267-
}
268-
269-
/**
270-
* Parse SSDP header information
271-
*/
272-
private fun parseSsdpHeaders(message: String): Map<String, String> {
273-
val headers = mutableMapOf<String, String>()
274-
message.lines().forEach { line ->
275-
val colonIndex = line.indexOf(':')
276-
if (colonIndex > 0) {
277-
val key = line.substring(0, colonIndex).trim().lowercase()
278-
val value = line.substring(colonIndex + 1).trim()
279-
headers[key] = value
280-
}
281-
}
282-
return headers
283-
}
284-
285262
/**
286263
* Process SSDP response
287264
*/
288265
private fun processSsdpResponse(response: String, fromAddress: InetAddress) {
289266
try {
290-
val headers = parseSsdpHeaders(response)
267+
val headers = SsdpHeaders.parse(response)
291268
val location = headers["location"]
292269
val usn = headers["usn"]
293270

app/src/main/java/com/yinnho/upnpcast/internal/localcast/LocalFileServer.kt

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import java.io.File
88
import java.io.FileInputStream
99
import java.lang.ref.WeakReference
1010
import kotlin.random.Random
11+
import com.yinnho.upnpcast.internal.util.MimeTypes
1112

1213
/**
1314
* Local file server based on NanoHTTPD
@@ -226,7 +227,7 @@ internal class LocalFileServer private constructor(
226227

227228
val response = newFixedLengthResponse(
228229
if (rangeHeader != null) Response.Status.PARTIAL_CONTENT else Response.Status.OK,
229-
getMimeType(file.name),
230+
MimeTypes.fromFileName(file.name),
230231
inputStream,
231232
contentLength
232233
)
@@ -247,28 +248,4 @@ internal class LocalFileServer private constructor(
247248
Log.d(TAG, "Serving file: ${file.name}, range: $rangeStart-$rangeEnd/$fileSize")
248249
return response
249250
}
250-
251-
/**
252-
* Resolve MIME type from the file extension; some TVs reject or
253-
* mis-handle unknown content types.
254-
*/
255-
private fun getMimeType(fileName: String): String {
256-
val extension = fileName.substringAfterLast('.', "").lowercase()
257-
return when (extension) {
258-
"mp4", "m4v" -> "video/mp4"
259-
"mkv", "webm" -> "video/x-matroska"
260-
"avi" -> "video/x-msvideo"
261-
"mov" -> "video/quicktime"
262-
"ts", "m2ts" -> "video/mp2t"
263-
"flv" -> "video/x-flv"
264-
"3gp" -> "video/3gpp"
265-
"mp3" -> "audio/mpeg"
266-
"flac" -> "audio/flac"
267-
"aac", "m4a" -> "audio/mp4"
268-
"wav" -> "audio/x-wav"
269-
"ogg", "oga" -> "audio/ogg"
270-
"srt" -> "text/srt"
271-
else -> "application/octet-stream"
272-
}
273-
}
274251
}

app/src/main/java/com/yinnho/upnpcast/internal/media/DlnaMediaController.kt

Lines changed: 9 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import java.util.Locale
1111
import com.yinnho.upnpcast.CastOptions
1212
import com.yinnho.upnpcast.internal.discovery.RemoteDevice
1313
import com.yinnho.upnpcast.internal.discovery.DeviceDescriptionParser
14+
import com.yinnho.upnpcast.internal.util.MetadataBuilder
15+
import com.yinnho.upnpcast.internal.util.SoapXml
16+
import com.yinnho.upnpcast.internal.util.UpnpTime
1417

1518
/**
1619
* DLNA media controller with SOAP-based control implementation
@@ -93,7 +96,7 @@ internal class DlnaMediaController(private val device: RemoteDevice) {
9396
if (!checkAvailable()) return@withContext false
9497

9598
try {
96-
val setUriSuccess = setMediaUri(mediaUrl, createMetadata(title, episodeLabel, mediaUrl, options))
99+
val setUriSuccess = setMediaUri(mediaUrl, MetadataBuilder.build(title, episodeLabel, mediaUrl, options))
97100
if (!setUriSuccess) return@withContext false
98101

99102
val playSuccess = control("play")
@@ -216,7 +219,7 @@ internal class DlnaMediaController(private val device: RemoteDevice) {
216219
is String -> value.toLongOrNull() ?: return false
217220
else -> return false
218221
}
219-
val timeString = formatTime(positionMs)
222+
val timeString = UpnpTime.format(positionMs)
220223
executeAVTransportAction("Seek", "\n <Unit>REL_TIME</Unit>\n <Target>${escapeXmlContent(timeString)}</Target>")
221224
}
222225

@@ -263,7 +266,7 @@ internal class DlnaMediaController(private val device: RemoteDevice) {
263266
</u:GetPositionInfo>
264267
""".trimIndent(),
265268
needResponse = true,
266-
parser = ::parsePositionInfo
269+
parser = SoapXml::parsePositionInfo
267270
)
268271

269272
/**
@@ -281,54 +284,9 @@ internal class DlnaMediaController(private val device: RemoteDevice) {
281284
</u:GetTransportInfo>
282285
""".trimIndent(),
283286
needResponse = true,
284-
parser = { response -> parseXmlValue(response, "CurrentTransportState") { it.trim() } }
287+
parser = { response -> SoapXml.extractValue(response, "CurrentTransportState")?.trim() }
285288
)
286289

287-
/**
288-
* Parse playback position information
289-
*/
290-
private fun parsePositionInfo(response: String): Pair<Long, Long>? {
291-
try {
292-
// Simple XML parsing to get RelTime and TrackDuration
293-
val relTimePattern = "<RelTime>(.*?)</RelTime>".toRegex()
294-
val durationPattern = "<TrackDuration>(.*?)</TrackDuration>".toRegex()
295-
296-
val relTimeMatch = relTimePattern.find(response)
297-
val durationMatch = durationPattern.find(response)
298-
299-
val currentTime = relTimeMatch?.groupValues?.get(1)?.let { parseTimeToMs(it) } ?: 0L
300-
val totalTime = durationMatch?.groupValues?.get(1)?.let { parseTimeToMs(it) } ?: 0L
301-
302-
return Pair(currentTime, totalTime)
303-
} catch (e: Exception) {
304-
Log.e(tag, "Failed to parse position info: ${e.message}")
305-
return null
306-
}
307-
}
308-
309-
/**
310-
* Parse time string to milliseconds
311-
*/
312-
private fun parseTimeToMs(timeString: String): Long {
313-
try {
314-
if (timeString == "NOT_IMPLEMENTED" || timeString.isEmpty()) {
315-
return 0L
316-
}
317-
318-
val parts = timeString.split(":")
319-
if (parts.size == 3) {
320-
val hours = parts[0].toLongOrNull() ?: 0L
321-
val minutes = parts[1].toLongOrNull() ?: 0L
322-
val seconds = parts[2].toDoubleOrNull() ?: 0.0
323-
324-
return (hours * 3600 + minutes * 60 + seconds).toLong() * 1000
325-
}
326-
return 0L
327-
} catch (e: Exception) {
328-
return 0L
329-
}
330-
}
331-
332290
/**
333291
* Generic SOAP request - handles both Boolean and String responses
334292
*/
@@ -387,42 +345,6 @@ internal class DlnaMediaController(private val device: RemoteDevice) {
387345
}
388346
}
389347

390-
/**
391-
* Generic XML value parser
392-
*/
393-
private fun <T> parseXmlValue(
394-
response: String,
395-
tagName: String,
396-
converter: (String) -> T?
397-
): T? {
398-
try {
399-
val pattern = "<$tagName>(.*?)</$tagName>".toRegex()
400-
val match = pattern.find(response)
401-
return match?.groupValues?.get(1)?.let { converter(it) }
402-
} catch (e: Exception) {
403-
Log.e(tag, "Failed to parse $tagName from response: ${e.message}")
404-
return null
405-
}
406-
}
407-
408-
/**
409-
* Parse volume from response
410-
*/
411-
private fun parseVolumeFromResponse(response: String): Int? =
412-
parseXmlValue(response, "CurrentVolume") { it.toIntOrNull() }
413-
414-
/**
415-
* Parse mute state from response
416-
*/
417-
private fun parseMuteFromResponse(response: String): Boolean? =
418-
parseXmlValue(response, "CurrentMute") { value ->
419-
when (value) {
420-
"1", "true", "True" -> true
421-
"0", "false", "False" -> false
422-
else -> null
423-
}
424-
}
425-
426348
/**
427349
* Basic XML escape (common characters)
428350
*/
@@ -446,70 +368,7 @@ internal class DlnaMediaController(private val device: RemoteDevice) {
446368
* XML escape for URLs (basic only)
447369
*/
448370
private fun escapeXmlUrl(url: String): String = escapeXmlBasic(url)
449-
450-
/**
451-
* Create DIDL-Lite metadata
452-
*
453-
* [CastOptions.metadata] is sent verbatim when provided. Otherwise the
454-
* metadata is generated, honoring the mimeType/upnpClass overrides and
455-
* attaching the subtitle resource when [CastOptions.subtitleUri] is set
456-
* (the Samsung `sec:SubtitleUri` extension is included as well).
457-
*/
458-
private fun createMetadata(
459-
title: String,
460-
episodeLabel: String,
461-
mediaUrl: String = "",
462-
options: CastOptions = CastOptions()
463-
): String {
464-
options.metadata?.let { return it }
465-
466-
val displayTitle = if (episodeLabel.isNotEmpty()) "$title - $episodeLabel" else title
467-
val safeDisplayTitle = escapeXmlContent(displayTitle)
468-
val safeMediaUrl = escapeXmlUrl(mediaUrl)
469-
470-
val mediaType = options.mimeType ?: when {
471-
mediaUrl.contains(".mp4", ignoreCase = true) -> "video/mp4"
472-
mediaUrl.contains(".mkv", ignoreCase = true) -> "video/x-matroska"
473-
mediaUrl.contains(".m3u8", ignoreCase = true) -> "application/vnd.apple.mpegurl"
474-
mediaUrl.contains(".mp3", ignoreCase = true) -> "audio/mpeg"
475-
else -> "video/mp4"
476-
}
477-
478-
val upnpClass = options.upnpClass ?: if (mediaType.startsWith("video") || mediaType.contains("mpegurl")) {
479-
"object.item.videoItem"
480-
} else {
481-
"object.item.audioItem.musicTrack"
482-
}
483-
484-
val safeSubtitleUri = options.subtitleUri?.let { escapeXmlUrl(it) }
485-
val subtitleRes = safeSubtitleUri
486-
?.let { "\n <res protocolInfo=\"http-get:*:${options.subtitleMimeType}:*\">$it</res>" }
487-
?: ""
488-
val subtitleElement = safeSubtitleUri
489-
?.let { "\n <sec:SubtitleUri>$it</sec:SubtitleUri>" }
490-
?: ""
491-
val secNamespace = if (safeSubtitleUri != null) " xmlns:sec=\"http://www.samsung.com/sec/\"" else ""
492371

493-
return """<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"$secNamespace>
494-
<item id="1" parentID="0" restricted="1">
495-
<dc:title>$safeDisplayTitle</dc:title>
496-
<upnp:class>$upnpClass</upnp:class>$subtitleElement
497-
<res protocolInfo="http-get:*:$mediaType:*">$safeMediaUrl</res>$subtitleRes
498-
</item>
499-
</DIDL-Lite>"""
500-
}
501-
502-
/**
503-
* Time format
504-
*/
505-
private fun formatTime(positionMs: Long): String {
506-
val totalSeconds = positionMs / 1000
507-
val hours = totalSeconds / 3600
508-
val minutes = (totalSeconds % 3600) / 60
509-
val seconds = totalSeconds % 60
510-
return String.format(Locale.ROOT, "%02d:%02d:%02d", hours, minutes, seconds)
511-
}
512-
513372
/**
514373
* Execute RenderingControl action - unified method for both set and get operations
515374
*/
@@ -538,12 +397,12 @@ internal class DlnaMediaController(private val device: RemoteDevice) {
538397
/**
539398
* Get current volume
540399
*/
541-
suspend fun getVolumeAsync(): Int? = executeRenderingControl("GetVolume", needResponse = true, parser = ::parseVolumeFromResponse)
400+
suspend fun getVolumeAsync(): Int? = executeRenderingControl("GetVolume", needResponse = true, parser = SoapXml::parseVolume)
542401

543402
/**
544403
* Get current mute state
545404
*/
546-
suspend fun getMuteAsync(): Boolean? = executeRenderingControl("GetMute", needResponse = true, parser = ::parseMuteFromResponse)
405+
suspend fun getMuteAsync(): Boolean? = executeRenderingControl("GetMute", needResponse = true, parser = SoapXml::parseMute)
547406

548407

549408

0 commit comments

Comments
 (0)