From ff5147fcb04210432cccecfe59db242a94e7c369 Mon Sep 17 00:00:00 2001 From: ThibaultBee <37510686+ThibaultBee@users.noreply.github.com> Date: Mon, 26 Aug 2024 13:41:27 +0200 Subject: [PATCH] fix(core): add a way to manipulate stream id format in `SrtUrl` --- .../srtdroid/core/models/SrtUrlTest.kt | 41 +++- .../core/extensions/StringExtensions.kt | 31 +++ .../srtdroid/core/models/SrtUri.kt | 184 ++++++++++++++++++ .../srtdroid/core/models/SrtUrl.kt | 76 +++++--- 4 files changed, 302 insertions(+), 30 deletions(-) create mode 100644 srtdroid-core/src/main/java/io/github/thibaultbee/srtdroid/core/extensions/StringExtensions.kt create mode 100644 srtdroid-core/src/main/java/io/github/thibaultbee/srtdroid/core/models/SrtUri.kt diff --git a/srtdroid-core/src/androidTest/java/io/github/thibaultbee/srtdroid/core/models/SrtUrlTest.kt b/srtdroid-core/src/androidTest/java/io/github/thibaultbee/srtdroid/core/models/SrtUrlTest.kt index d139536c..e9f3bb43 100644 --- a/srtdroid-core/src/androidTest/java/io/github/thibaultbee/srtdroid/core/models/SrtUrlTest.kt +++ b/srtdroid-core/src/androidTest/java/io/github/thibaultbee/srtdroid/core/models/SrtUrlTest.kt @@ -3,6 +3,7 @@ package io.github.thibaultbee.srtdroid.core.models import io.github.thibaultbee.srtdroid.core.enums.SockOpt import io.github.thibaultbee.srtdroid.core.enums.Transtype import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class SrtUrlTest { @@ -44,6 +45,33 @@ class SrtUrlTest { assertEquals("1234", srtUrl.passphrase) } + @Test + fun srtUrlWithStreamId() { + var srtUrl = + SrtUrl("srt://host:9000?srt_streamid=abcde") + assertEquals("abcde", srtUrl.streamId) + + srtUrl = + SrtUrl("srt://host:9000?streamid=abcde") + assertEquals("abcde", srtUrl.streamId) + } + + @Test + fun srtUrlWithStreamIdFormat() { + var srtUrl = + SrtUrl("srt://host:9000?srt_streamid=#!::u=admin,r=bluesbrothers1_hi") + assertEquals("#!::u=admin,r=bluesbrothers1_hi", srtUrl.streamId) + + srtUrl = + SrtUrl("srt://host:9000?streamid=#!::u=admin,r=bluesbrothers1_hi") + assertEquals("#!::u=admin,r=bluesbrothers1_hi", srtUrl.streamId) + + srtUrl = + SrtUrl("srt://host:9000?streamid=#!::u=admin,r=bluesbrothers1_hi&transtype=live") + assertEquals("#!::u=admin,r=bluesbrothers1_hi", srtUrl.streamId) + assertEquals(Transtype.LIVE, srtUrl.transtype) + } + @Test fun srtUrlApplyToSocket() { val srtUrl = SrtUrl(hostname = "127.0.0.1", port = 9000, connectTimeoutInMs = 1234) @@ -55,7 +83,18 @@ class SrtUrlTest { @Test fun srtUrlToUri() { val srtUrl = SrtUrl(hostname = "127.0.0.1", port = 9000, connectTimeoutInMs = 1234) - val uri = srtUrl.uri + val uri = srtUrl.srtUri assertEquals(1234, uri.getQueryParameter("connect_timeout")?.toInt()) } + + @Test + fun srtUrlWithStreamIdFormatToUri() { + val srtUrl = SrtUrl( + hostname = "127.0.0.1", + port = 9000, + streamId = "#!::u=admin,r=bluesbrothers1_hi" + ) + val uri = srtUrl.srtUri + assertTrue(uri.toString().contains("streamid=#!::u=admin,r=bluesbrothers1_hi")) + } } \ No newline at end of file diff --git a/srtdroid-core/src/main/java/io/github/thibaultbee/srtdroid/core/extensions/StringExtensions.kt b/srtdroid-core/src/main/java/io/github/thibaultbee/srtdroid/core/extensions/StringExtensions.kt new file mode 100644 index 00000000..af0f5dcc --- /dev/null +++ b/srtdroid-core/src/main/java/io/github/thibaultbee/srtdroid/core/extensions/StringExtensions.kt @@ -0,0 +1,31 @@ +package io.github.thibaultbee.srtdroid.core.extensions + +fun String.replaceBetween( + prefix: String, + suffix: String, + value: String, +): String { + val startIndex = indexOf(prefix) + if (startIndex < 0) return this + val endIndex = indexOf(suffix, startIndex + prefix.length) + + return if (endIndex < 0) { + substring(0, startIndex + prefix.length) + value + } else { + substring( + 0, + startIndex + prefix.length + ) + value + substring(endIndex) + } +} + +fun String.substringBetween(prefix: String, suffix: String): String { + val startIndex = indexOf(prefix) + if (startIndex < 0) return "" + val endIndex = indexOf(suffix, startIndex + prefix.length) + return if (endIndex < 0) { + substring(startIndex + prefix.length) + } else { + substring(startIndex + prefix.length, endIndex) + } +} diff --git a/srtdroid-core/src/main/java/io/github/thibaultbee/srtdroid/core/models/SrtUri.kt b/srtdroid-core/src/main/java/io/github/thibaultbee/srtdroid/core/models/SrtUri.kt new file mode 100644 index 00000000..b7ab3929 --- /dev/null +++ b/srtdroid-core/src/main/java/io/github/thibaultbee/srtdroid/core/models/SrtUri.kt @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2024 Thibault B. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.thibaultbee.srtdroid.core.models + +import android.net.Uri +import io.github.thibaultbee.srtdroid.core.extensions.replaceBetween +import io.github.thibaultbee.srtdroid.core.extensions.substringBetween +import io.github.thibaultbee.srtdroid.core.models.SrtUrl.Companion.SRT_SCHEME +import io.github.thibaultbee.srtdroid.core.models.SrtUrl.Companion.SRT_STREAM_ID_QUERY_PARAMETER +import io.github.thibaultbee.srtdroid.core.models.SrtUrl.Companion.STREAM_ID_QUERY_PARAMETER +import kotlin.random.Random + +/** + * An [URI] with specific SRT parameter for streamId. + * StreamId query is not RFC 2396 compliant. Example of SRT syntax: #!::u=admin,r=bluesbrothers1_hi + * Parameters after streamId might be lost because they will be interpreted as fragment. + * + * The purpose of this class is to provide a way to get streamId and to get all parameters from the [rawUri]. + */ + +class SrtUri +internal constructor(private val rawUri: Uri) { + init { + require(rawUri.scheme == SRT_SCHEME) { "Uri scheme must be $SRT_SCHEME" } + } + + private val fakeStreamId = "FAKE_STREAM_ID" + Random.nextInt() + + private val rawUriString by lazy { rawUri.toString() } + private val hasStreamIdFormat by lazy { rawUriString.contains("$STREAM_ID_QUERY_PARAMETER=#!") } + private val hasSrtStreamIdFormat by lazy { + rawUriString.contains("$SRT_STREAM_ID_QUERY_PARAMETER=#!") + } + + private val streamIdParameterName by lazy { + when { + rawUri.getQueryParameter(STREAM_ID_QUERY_PARAMETER) != null -> STREAM_ID_QUERY_PARAMETER + rawUri.getQueryParameter(SRT_STREAM_ID_QUERY_PARAMETER) != null -> SRT_STREAM_ID_QUERY_PARAMETER + else -> null + } + } + + private val uri: Uri by lazy { + if (hasStreamIdFormat || hasSrtStreamIdFormat) { + // Replace stream Id format #! with fake to avoid Uri parsing error + val correctedUriString = + rawUriString.replaceBetween("$streamIdParameterName=", "&", fakeStreamId) + Uri.parse(correctedUriString) + } else { + rawUri + } + } + + val streamId: String? by lazy { + if (streamIdParameterName != null) { + if (hasStreamIdFormat || hasSrtStreamIdFormat) { + rawUriString.substringBetween("$streamIdParameterName=", "&") + } else { + rawUri.getQueryParameter(streamIdParameterName) + } + } else { + null + } + } + + /** + * Gets the scheme from the URI. + */ + val scheme by lazy { uri.scheme } + + /** + * Gets the encoded host from the authority for this URI. + */ + val host by lazy { uri.host } + + /** + * Gets the port from the authority for this URI. + */ + val port by lazy { uri.port } + + /** + * Returns a set of the unique names of all query parameters. Iterating over the set will return the names in order of their first occurrence. + */ + val queryParameterNames: Set by lazy { + uri.queryParameterNames + } + + /** + * Searches the query string for the first value with the given key. + * @param key the query parameter name + * @return the decoded value or null if no parameter is found + */ + fun getQueryParameter(key: String): String? { + if (key == STREAM_ID_QUERY_PARAMETER || key == SRT_STREAM_ID_QUERY_PARAMETER) { + throw IllegalArgumentException("streamId is a reserved key") + } + return uri.getQueryParameter(key) + } + + /** + * Returns [Uri] as [String]. + */ + override fun toString(): String { + val streamId = streamId ?: return uri.toString() + return uri.toString().replace(fakeStreamId, streamId) + } + + companion object { + /** + * Parse [uriString] to [SrtUri] + * + * @param uriString Uri string to parse + * @return [SrtUri] + */ + fun parse(uriString: String): SrtUri { + return parse(Uri.parse(uriString)) + } + + /** + * Parse [uri] to [SrtUri] + * + * @param uri Uri to parse + * @return [SrtUri] + */ + fun parse(uri: Uri): SrtUri { + return SrtUri(uri) + } + } + + /** + * Builder for [SrtUri] + */ + internal class Builder { + private val builder = Uri.Builder().scheme(SRT_SCHEME) + private var streamId: String? = null + + fun streamId(streamId: String): Builder { + this.streamId = streamId + return this + } + + fun appendQueryParameter(key: String, value: String): Builder { + if ((key == STREAM_ID_QUERY_PARAMETER) || (key == SRT_STREAM_ID_QUERY_PARAMETER)) { + throw IllegalArgumentException("streamId is a reserved key") + } + builder.appendQueryParameter(key, value) + return this + } + + fun encodedAuthority(authority: String?): Builder { + builder.encodedAuthority(authority) + return this + } + + fun build(): SrtUri { + val streamId = streamId ?: return SrtUri(builder.build()) + if (streamId.contains("#!")) { + val fakeStreamId = "FAKE_STREAM_ID" + Random.nextInt() + builder.appendQueryParameter(STREAM_ID_QUERY_PARAMETER, fakeStreamId) + val uri = builder.build() + val correctedUriString = uri.toString().replace( + fakeStreamId, streamId + ) + return SrtUri(Uri.parse(correctedUriString)) + } else { + builder.appendQueryParameter(STREAM_ID_QUERY_PARAMETER, streamId) + return SrtUri(builder.build()) + } + } + } +} diff --git a/srtdroid-core/src/main/java/io/github/thibaultbee/srtdroid/core/models/SrtUrl.kt b/srtdroid-core/src/main/java/io/github/thibaultbee/srtdroid/core/models/SrtUrl.kt index c39d3b56..08266c45 100644 --- a/srtdroid-core/src/main/java/io/github/thibaultbee/srtdroid/core/models/SrtUrl.kt +++ b/srtdroid-core/src/main/java/io/github/thibaultbee/srtdroid/core/models/SrtUrl.kt @@ -28,15 +28,17 @@ import java.security.InvalidParameterException /** * Extracts [SrtUrl] from a FFmpeg format [String]: srt://hostname:port[?options] */ -fun SrtUrl(url: String): SrtUrl { - val uri = Uri.parse(url) - return SrtUrl(uri) -} +fun SrtUrl(url: String) = SrtUrl(Uri.parse(url)) /** * Extracts [SrtUrl] from a FFmpeg format [Uri]. */ -fun SrtUrl(uri: Uri): SrtUrl { +fun SrtUrl(uri: Uri) = SrtUrl(SrtUri(uri)) + +/** + * Extracts [SrtUrl] from a FFmpeg format [SrtUri]. + */ +internal fun SrtUrl(uri: SrtUri): SrtUrl { if (uri.scheme != SrtUrl.SRT_SCHEME) { throw InvalidParameterException("URL $uri is not an srt URL") } @@ -44,32 +46,44 @@ fun SrtUrl(uri: Uri): SrtUrl { ?: throw InvalidParameterException("Failed to parse URL $uri: unknown host") val port = uri.port + val streamId = uri.streamId + val connectTimeoutInMs = uri.getQueryParameter(SrtUrl.CONNECTION_TIMEOUT_QUERY_PARAMETER)?.toLong() val flightFlagSize = uri.getQueryParameter(SrtUrl.FFS_QUERY_PARAMETER)?.toInt() - val inputBandwidth = uri.getQueryParameter(SrtUrl.INPUT_BANDWIDTH_QUERY_PARAMETER)?.toInt() + val inputBandwidth = + uri.getQueryParameter(SrtUrl.INPUT_BANDWIDTH_QUERY_PARAMETER)?.toInt() val iptos = uri.getQueryParameter(SrtUrl.IPTOS_QUERY_PARAMETER)?.toInt() val ipttl = uri.getQueryParameter(SrtUrl.IPTTL_QUERY_PARAMETER)?.toInt() val latencyInUs = uri.getQueryParameter(SrtUrl.LATENCY_QUERY_PARAMETER)?.toLong() - val listenTimeoutInUs = uri.getQueryParameter(SrtUrl.LISTEN_TIMEOUT_QUERY_PARAMETER)?.toLong() - val maxBandwidth = uri.getQueryParameter(SrtUrl.MAX_BANDWIDTH_QUERY_PARAMETER)?.toLong() - val mode = uri.getQueryParameter(SrtUrl.MODE_QUERY_PARAMETER)?.let { SrtUrl.Mode.entryOf(it) } - val maxSegmentSize = uri.getQueryParameter(SrtUrl.MAX_SEGMENT_SIZE_QUERY_PARAMETER)?.toInt() - val nakReport = uri.getQueryParameter(SrtUrl.NAK_REPORT_QUERY_PARAMETER)?.toInt()?.toBoolean() + val listenTimeoutInUs = + uri.getQueryParameter(SrtUrl.LISTEN_TIMEOUT_QUERY_PARAMETER)?.toLong() + val maxBandwidth = + uri.getQueryParameter(SrtUrl.MAX_BANDWIDTH_QUERY_PARAMETER)?.toLong() + val mode = + uri.getQueryParameter(SrtUrl.MODE_QUERY_PARAMETER)?.let { SrtUrl.Mode.entryOf(it) } + val maxSegmentSize = + uri.getQueryParameter(SrtUrl.MAX_SEGMENT_SIZE_QUERY_PARAMETER)?.toInt() + val nakReport = + uri.getQueryParameter(SrtUrl.NAK_REPORT_QUERY_PARAMETER)?.toInt()?.toBoolean() val overheadBandwidth = uri.getQueryParameter(SrtUrl.OVERHEAD_BANDWIDTH_QUERY_PARAMETER)?.toInt() val passphrase = uri.getQueryParameter(SrtUrl.PASS_PHRASE_QUERY_PARAMETER) val enforcedEncryption = - uri.getQueryParameter(SrtUrl.ENFORCED_ENCRYPTION_QUERY_PARAMETER)?.toInt()?.toBoolean() - val kmRefreshRate = uri.getQueryParameter(SrtUrl.KM_REFRESH_RATE_QUERY_PARAMETER)?.toInt() - val kmPreannounce = uri.getQueryParameter(SrtUrl.KM_PREANNOUNCE_QUERY_PARAMETER)?.toInt() + uri.getQueryParameter(SrtUrl.ENFORCED_ENCRYPTION_QUERY_PARAMETER)?.toInt() + ?.toBoolean() + val kmRefreshRate = + uri.getQueryParameter(SrtUrl.KM_REFRESH_RATE_QUERY_PARAMETER)?.toInt() + val kmPreannounce = + uri.getQueryParameter(SrtUrl.KM_PREANNOUNCE_QUERY_PARAMETER)?.toInt() val senderDropDelayInUs = uri.getQueryParameter(SrtUrl.SENDER_DROP_DELAY_QUERY_PARAMETER)?.toLong() val payloadSize = uri.getQueryParameter(SrtUrl.PAYLOAD_SIZE_QUERY_PARAMETER)?.toInt() ?: uri.getQueryParameter( SrtUrl.PACKET_SIZE_QUERY_PARAMETER )?.toInt() - val peerLatencyInUs = uri.getQueryParameter(SrtUrl.PEER_LATENCY_QUERY_PARAMETER)?.toLong() + val peerLatencyInUs = + uri.getQueryParameter(SrtUrl.PEER_LATENCY_QUERY_PARAMETER)?.toLong() val pbKeyLength = uri.getQueryParameter(SrtUrl.PB_KEY_LENGTH_QUERY_PARAMETER)?.toInt() val receiverLatencyInUs = uri.getQueryParameter(SrtUrl.RECEIVER_LATENCY_QUERY_PARAMETER)?.toLong() @@ -81,26 +95,31 @@ fun SrtUrl(uri: Uri): SrtUrl { val enableTooLatePacketDrop = uri.getQueryParameter(SrtUrl.ENABLE_TOO_LATE_PACKET_DROP_QUERY_PARAMETER)?.toInt() ?.toBoolean() - val sendBufferSize = uri.getQueryParameter(SrtUrl.SEND_BUFFER_SIZE_QUERY_PARAMETER)?.toInt() - val recvBufferSize = uri.getQueryParameter(SrtUrl.RECV_BUFFER_SIZE_QUERY_PARAMETER)?.toInt() + val sendBufferSize = + uri.getQueryParameter(SrtUrl.SEND_BUFFER_SIZE_QUERY_PARAMETER)?.toInt() + val recvBufferSize = + uri.getQueryParameter(SrtUrl.RECV_BUFFER_SIZE_QUERY_PARAMETER)?.toInt() val lossMaxTTL = uri.getQueryParameter(SrtUrl.LOSS_MAX_TTL_QUERY_PARAMETER)?.toInt() val minVersion = uri.getQueryParameter(SrtUrl.MIN_VERSION_QUERY_PARAMETER)?.toInt() - val streamId = uri.getQueryParameter(SrtUrl.STREAM_ID_QUERY_PARAMETER) ?: uri.getQueryParameter( - SrtUrl.SRT_STREAM_ID_QUERY_PARAMETER - ) + val smoother = - uri.getQueryParameter(SrtUrl.SMOOTHER_QUERY_PARAMETER)?.let { Transtype.entryOf(it) } + uri.getQueryParameter(SrtUrl.SMOOTHER_QUERY_PARAMETER) + ?.let { Transtype.entryOf(it) } val enableMessageApi = - uri.getQueryParameter(SrtUrl.ENABLE_MESSAGE_API_QUERY_PARAMETER)?.toInt()?.toBoolean() + uri.getQueryParameter(SrtUrl.ENABLE_MESSAGE_API_QUERY_PARAMETER)?.toInt() + ?.toBoolean() val transtype = - uri.getQueryParameter(SrtUrl.TRANSTYPE_QUERY_PARAMETER)?.let { Transtype.entryOf(it) } + uri.getQueryParameter(SrtUrl.TRANSTYPE_QUERY_PARAMETER) + ?.let { Transtype.entryOf(it) } val lingerInS = uri.getQueryParameter(SrtUrl.LINGER_QUERY_PARAMETER)?.toInt() val enableTimestampBasedPacketDelivery = uri.getQueryParameter(SrtUrl.ENABLE_TIMESTAMP_BASED_PACKET_DELIVERY_QUERY_PARAMETER) ?.toInt()?.toBoolean() val unknownParameters = - uri.queryParameterNames.find { SrtUrl.supportedQueryParameterList.contains(it).not() } + uri.queryParameterNames.find { + SrtUrl.supportedQueryParameterList.contains(it).not() + } if (unknownParameters != null) { throw InvalidParameterException("Failed to parse URL $uri: unknown parameter(s): $unknownParameters") } @@ -206,13 +225,12 @@ data class SrtUrl( } } - val uri: Uri by lazy { + val srtUri: SrtUri by lazy { buildUri() } - private fun buildUri(): Uri { - val uriBuilder = Uri.Builder() - .scheme(SRT_SCHEME) + private fun buildUri(): SrtUri { + val uriBuilder = SrtUri.Builder() .encodedAuthority("$hostname:$port") connectTimeoutInMs?.let { uriBuilder.appendQueryParameter( @@ -363,7 +381,7 @@ data class SrtUrl( it.toString() ) } - streamId?.let { uriBuilder.appendQueryParameter(STREAM_ID_QUERY_PARAMETER, it) } + streamId?.let { uriBuilder.streamId(it) } smoother?.let { uriBuilder.appendQueryParameter(SMOOTHER_QUERY_PARAMETER, it.value) } enableMessageApi?.let { uriBuilder.appendQueryParameter(