Search code examples
androidencryptionaesexoplayer

How to play an encrypted remote audio/video file when streaming on the fly


I need to play an encrypted remote audio file on the fly using ExoPlayer. I've implemented a custom DataSource to handle read() and open() methods. It works fine when the audio file starts from the beginning (position = 0) but when it starts from some other position or when I seek the audio, the cipher breaks down and crops the file resulting in ExoPlayer error. Through the search, I realized that the problem refers to the skip() method of CipherInputStream which should get overridden regarding the encryption algorithm. Having the above conditions, how should I implement the skip() method?

Here is HttpCipherEncryptedDataSource as the custom DataSource:


class HttpCipherEncryptedDataSource(
    private val key: ByteArray,
) : DataSource {

    private val connectionMaker = HttpConnectionMaker()

    private var connection: HttpURLConnection? = null
    private var cipherInputStream: CipherHttpInputStream? = null
    private var dataSpec: DataSpec? = null
    private var uri: Uri? = null

    private var bytesToRead: Long = 0
    private var bytesRead: Long = 0
    private var isOpen = false

    override fun open(dataSpec: DataSpec): Long {
        this.uri = dataSpec.uri
        this.dataSpec = dataSpec

        // make server connection
        connection = connectionMaker.make(dataSpec)
        val responseCode = connection!!.responseCode
        val responseMessage = connection!!.responseMessage

        // Check for a valid response code.
        if (responseCode < 200 || responseCode > 299) {
            val headers = connection!!.headerFields
            if (responseCode == 416) {
                val documentSize =
                    HttpUtil.getDocumentSize(connection!!.getHeaderField(HttpHeaders.CONTENT_RANGE))
                if (dataSpec.position == documentSize) {
                    isOpen = true
                    return if (dataSpec.length != C.LENGTH_UNSET.toLong()) dataSpec.length else 0
                }
            }
            val errorStream = connection!!.errorStream
            val errorResponseBody = try {
                if (errorStream != null) Util.toByteArray(errorStream) else Util.EMPTY_BYTE_ARRAY
            } catch (e: IOException) {
                Util.EMPTY_BYTE_ARRAY
            }
            connectionMaker.closeConnection()
            val cause: IOException? =
                if (responseCode == 416) DataSourceException(PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE) else null
            throw InvalidResponseCodeException(
                responseCode, responseMessage, cause, headers, dataSpec, errorResponseBody
            )
        }

        // calculate current position
        val bytesToSkip =
            if (responseCode == 200 && dataSpec.position != 0L) dataSpec.position else 0

        // Determine the length of the data to be read, after skipping.
        val isCompressed = isCompressed(connection!!)
        if (!isCompressed) {
            bytesToRead = if (dataSpec.length != C.LENGTH_UNSET.toLong()) {
                dataSpec.length
            } else {
                val contentLength = HttpUtil.getContentLength(
                    connection!!.getHeaderField(HttpHeaders.CONTENT_LENGTH),
                    connection!!.getHeaderField(HttpHeaders.CONTENT_RANGE)
                )
                if (contentLength != C.LENGTH_UNSET.toLong()) contentLength - bytesToSkip else C.LENGTH_UNSET.toLong()
            }
        } else {
            // Gzip is enabled. If the server opts to use gzip then the content length in the response
            // will be that of the compressed data, which isn't what we want. Always use the dataSpec
            // length in this case.
            bytesToRead = dataSpec.length
        }

        var encryptedStream: InputStream?
        try {
            encryptedStream = connection!!.inputStream
            if (isCompressed) {
                encryptedStream = GZIPInputStream(encryptedStream)
            }
            setupCipherInputStream(encryptedStream!!)
            cipherInputStream?.forceSkip(dataSpec.position)
        } catch (e: IOException) {
            connectionMaker.closeConnection()
            throw HttpDataSourceException(
                e,
                dataSpec,
                PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
                HttpDataSourceException.TYPE_OPEN
            )
        }
        isOpen = true
        return bytesToRead
    }

    private fun setupCipherInputStream(encryptedFileStream: InputStream) {
        val keySpec = SecretKeySpec(
            key,
            "AES"
        )
        val cipher = Cipher.getInstance(
            "AES/ECB/PCSK5Padding"
        )
        cipherInputStream = CipherHttpInputStream(
            encryptedFileStream,
            cipher,
            keySpec
        )
    }

    private fun isCompressed(connection: HttpURLConnection): Boolean {
        val contentEncoding = connection.getHeaderField("Content-Encoding")
        return "gzip".equals(contentEncoding, ignoreCase = true)
    }

    @Throws(HttpDataSourceException::class)
    override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
        try {
            var readLength = length
            if (readLength == 0) {
                return 0
            }
            if (bytesToRead != C.LENGTH_UNSET.toLong()) {
                val bytesRemaining: Long = bytesToRead - bytesRead
                if (bytesRemaining == 0L) {
                    return C.RESULT_END_OF_INPUT
                }
                readLength = Math.min(readLength.toLong(), bytesRemaining).toInt()
            }

            val read = Util.castNonNull<InputStream>(cipherInputStream).read(buffer, offset, readLength)
            if (read == -1) {
                return C.RESULT_END_OF_INPUT
            }

            bytesRead += read.toLong()
            return read
        } catch (e: IOException) {
            throw HttpDataSourceException.createForIOException(
                e, Util.castNonNull(dataSpec), HttpDataSourceException.TYPE_READ
            )
        }
    }

    override fun addTransferListener(transferListener: TransferListener) {}

    override fun getUri() = uri

    @Throws(HttpDataSourceException::class)
    override fun close() {
        try {
            val inputStream: InputStream? = this.cipherInputStream
            if (inputStream != null) {
                val bytesRemaining =
                    if (bytesToRead == C.LENGTH_UNSET.toLong()) C.LENGTH_UNSET.toLong() else bytesToRead - bytesRead
                maybeTerminateInputStream(connection, bytesRemaining)
                try {
                    inputStream.close()
                } catch (e: IOException) {
                    throw HttpDataSourceException(
                        e,
                        Util.castNonNull(dataSpec),
                        PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
                        HttpDataSourceException.TYPE_CLOSE
                    )
                }
            }
        } finally {
            cipherInputStream = null
            connectionMaker.closeConnection()
            if (isOpen) {
                isOpen = false
            }
        }
    }

    private fun maybeTerminateInputStream(connection: HttpURLConnection?, bytesRemaining: Long) {
        if (connection == null || Util.SDK_INT < 19 || Util.SDK_INT > 20) {
            return
        }
        try {
            val inputStream = connection.inputStream
            if (bytesRemaining == C.LENGTH_UNSET.toLong()) {
                // If the input stream has already ended, do nothing. The socket may be re-used.
                if (inputStream.read() == -1) {
                    return
                }
            } else if (bytesRemaining <= MAX_BYTES_TO_DRAIN) {
                // There isn't much data left. Prefer to allow it to drain, which may allow the socket to be
                // re-used.
                return
            }
            val className = inputStream.javaClass.name
            if ("com.android.okhttp.internal.http.HttpTransport\$ChunkedInputStream" == className
                || ("com.android.okhttp.internal.http.HttpTransport\$FixedLengthInputStream"
                        == className)
            ) {
                val superclass: Class<in InputStream>? = inputStream.javaClass.superclass
                val unexpectedEndOfInput =
                    Assertions.checkNotNull(superclass).getDeclaredMethod("unexpectedEndOfInput")
                unexpectedEndOfInput.isAccessible = true
                unexpectedEndOfInput.invoke(inputStream)
            }
        } catch (e: Exception) {
            // If an IOException then the connection didn't ever have an input stream, or it was closed
            // already. If another type of exception then something went wrong, most likely the device
            // isn't using okhttp.
            e.printStackTrace()
        }
    }

    companion object {
        private const val MAX_BYTES_TO_DRAIN: Long = 2048
    }
}

and also CipherHttpInputStream to handle skip method:


class CipherHttpInputStream(
    private val upstream: InputStream,
    private val cipher: Cipher,
    private val secretKeySpec: SecretKeySpec,
) : CipherInputStream(upstream, cipher) {

    private val MAX_SKIP_BUFFER_SIZE = 2048

    fun forceSkip(bytesToSkip: Long) {
        var remaining: Long = bytesToSkip
        var nr: Int

        if (bytesToSkip <= 0) {
            return
        }
        val size = Math.min(MAX_SKIP_BUFFER_SIZE.toLong(), remaining).toInt()
        val skipBuffer = ByteArray(size)
        initCipher()
        while (remaining > 0) {
            nr = upstream.read(skipBuffer, 0, Math.min(size.toLong(), remaining).toInt())
            if (nr < 0) {
                break
            }
            remaining -= nr.toLong()
        }
    }

    private fun initCipher() {
        cipher.init(
            Cipher.DECRYPT_MODE,
            secretKeySpec,
        )
    }

    override fun available(): Int {
        return upstream.available()
    }

}

Solution

  • After days of searching and debugging, eventually I came up with the problem. Since this DataSource is responsible for playing a remote encrypted Audio file, every time that open() is called (particularly when seeking), a new InputStream is instantiated wrapping the file streams starting at the exact position in which ExoPlayer is supposed to start. So there is no need to use the skip() method in this scenario. However, the bug still refers back to the starting position of the file. As far as CipherInputStream reads the upstream block by block(cipher.blockSize) if the starting position is not divisible by cipher.blockSize, it will result in ERROR_CODE_PARSING_CONTAINER_MALFORMED ExoPlayer error. To tackle this obstacle, I wrote the following function to calculate the appropriate starting position regarding the cipher block size and store it inside a new dataSpec.

    Here is the function:

    // check if the new position divided by cipher.blockSize
    // results in zero. If not truncate the remaining.
    private fun modifyBytesBlocks(dataSpec: DataSpec): DataSpec {
        val bytesSinceStartOfCurrentBlock = dataSpec.position % cipher.blockSize
        var bytesUntilPreviousBlockStart =
            dataSpec.position - bytesSinceStartOfCurrentBlock - cipher.blockSize
        if (bytesUntilPreviousBlockStart < 0) bytesUntilPreviousBlockStart = 0
    
        return DataSpec(
            dataSpec.uri,
            dataSpec.httpMethod,
            dataSpec.httpBody,
            bytesUntilPreviousBlockStart,
            bytesUntilPreviousBlockStart,
            dataSpec.length,
            dataSpec.key,
            dataSpec.flags,
            dataSpec.httpRequestHeaders
        )
    }
    

    which should be used at the beginning of open, replacing the original dataSpec. Here is the whole working custom DataSource source:

    
    class HttpCipherEncryptedDataSource(key: ByteArray) : DataSource {
    
        private val connectionMaker = HttpConnectionMaker()
        private val keySpec = SecretKeySpec(
            key,
            "AES"
        )
       private val cipher = Cipher.getInstance(
            "AES/ECB/PKCS5Padding"
        )
    
        private var connection: HttpURLConnection? = null
        private var cipherInputStream: CipherHttpInputStream? = null
        private var updatedDataSpec: DataSpec? = null
        private var uri: Uri? = null
    
        private var bytesToRead: Long = 0
        private var bytesRead: Long = 0
        private var isOpen = false
    
        override fun open(dataSpec: DataSpec): Long {
            bytesRead = 0
            bytesToRead = 0
            this.uri = dataSpec.uri
            this.updatedDataSpec = modifyBytesBlocks(dataSpec)
            val responseCode: Int
            val responseMessage: String
            try {
                // make server connection
                connection = connectionMaker.make(updatedDataSpec!!)
                 responseCode = connection!!.responseCode
                 responseMessage = connection!!.responseMessage
            } catch (e: IOException) {
                connectionMaker.closeConnection()
                throw HttpDataSourceException.createForIOException(
                    e, dataSpec, HttpDataSourceException.TYPE_OPEN
                )
            }
            // Check for a valid response code.
            if (responseCode < 200 || responseCode > 299) {
                val headers = connection!!.headerFields
                if (responseCode == 416) {
                    val documentSize =
                        HttpUtil.getDocumentSize(connection!!.getHeaderField(HttpHeaders.CONTENT_RANGE))
                    if (updatedDataSpec!!.position == documentSize) {
                        isOpen = true
                        return if (updatedDataSpec!!.length != C.LENGTH_UNSET.toLong()) updatedDataSpec!!.length else 0
                    }
                }
                val errorStream = connection!!.errorStream
                val errorResponseBody = try {
                    if (errorStream != null) Util.toByteArray(errorStream) else Util.EMPTY_BYTE_ARRAY
                } catch (e: IOException) {
                    Util.EMPTY_BYTE_ARRAY
                }
                connectionMaker.closeConnection()
                val cause: IOException? =
                    if (responseCode == 416) DataSourceException(PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE) else null
                throw InvalidResponseCodeException(
                    responseCode, responseMessage, cause, headers, updatedDataSpec!!, errorResponseBody
                )
            }
    
            // calculate current position
            val bytesToSkip =
                if (responseCode == 200 && updatedDataSpec!!.position != 0L) updatedDataSpec!!.position else 0
    
            // Determine the length of the data to be read, after skipping.
            val isCompressed = isCompressed(connection!!)
            if (!isCompressed) {
                bytesToRead = if (updatedDataSpec!!.length != C.LENGTH_UNSET.toLong()) {
                    updatedDataSpec!!.length
                } else {
                    val contentLength = HttpUtil.getContentLength(
                        connection!!.getHeaderField(HttpHeaders.CONTENT_LENGTH),
                        connection!!.getHeaderField(HttpHeaders.CONTENT_RANGE)
                    )
                    if (contentLength != C.LENGTH_UNSET.toLong()) contentLength - bytesToSkip else C.LENGTH_UNSET.toLong()
                }
            } else {
                // Gzip is enabled. If the server opts to use gzip then the content length in the response
                // will be that of the compressed data, which isn't what we want. Always use the dataSpec
                // length in this case.
                bytesToRead = updatedDataSpec!!.length
            }
    
            var encryptedStream: InputStream?
            try {
                encryptedStream = connection!!.inputStream
                if (isCompressed) {
                    encryptedStream = GZIPInputStream(encryptedStream)
                }
                setupCipherInputStream(encryptedStream!!)
            } catch (e: IOException) {
                connectionMaker.closeConnection()
                throw HttpDataSourceException(
                    e,
                    updatedDataSpec!!,
                    PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
                    HttpDataSourceException.TYPE_OPEN
                )
            }
            isOpen = true
            return bytesToRead
        }
    
        // check if the new position divided by cipher.blockSize
        // results in zero. If not truncate the remaining.
        private fun modifyBytesBlocks(dataSpec: DataSpec): DataSpec {
            val bytesSinceStartOfCurrentBlock = dataSpec.position % cipher.blockSize
            var bytesUntilPreviousBlockStart =
                dataSpec.position - bytesSinceStartOfCurrentBlock - cipher.blockSize
            if (bytesUntilPreviousBlockStart < 0) bytesUntilPreviousBlockStart = 0
    
            return DataSpec(
                dataSpec.uri,
                dataSpec.httpMethod,
                dataSpec.httpBody,
                bytesUntilPreviousBlockStart,
                bytesUntilPreviousBlockStart,
                dataSpec.length,
                dataSpec.key,
                dataSpec.flags,
                dataSpec.httpRequestHeaders
            )
        }
    
        private fun setupCipherInputStream(encryptedFileStream: InputStream) {
            cipher.init(Cipher.DECRYPT_MODE, keySpec)
            cipherInputStream = CipherHttpInputStream(
                encryptedFileStream,
                cipher
            )
        }
    
        private fun isCompressed(connection: HttpURLConnection): Boolean {
            val contentEncoding = connection.getHeaderField("Content-Encoding")
            return "gzip".equals(contentEncoding, ignoreCase = true)
        }
    
        @Throws(HttpDataSourceException::class)
        override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
            try {
                var readLength = length
                if (readLength == 0) {
                    return 0
                }
                if (bytesToRead != C.LENGTH_UNSET.toLong()) {
                    val bytesRemaining: Long = bytesToRead - bytesRead
                    if (bytesRemaining == 0L) {
                        return C.RESULT_END_OF_INPUT
                    }
                    readLength = Math.min(readLength.toLong(), bytesRemaining).toInt()
                }
    
                val read =
                    Util.castNonNull<InputStream>(cipherInputStream).read(buffer, offset, readLength)
                if (read == -1) {
                    return C.RESULT_END_OF_INPUT
                }
    
                bytesRead += read.toLong()
                return read
            } catch (e: IOException) {
                throw HttpDataSourceException.createForIOException(
                    e, Util.castNonNull(updatedDataSpec), HttpDataSourceException.TYPE_READ
                )
            }
        }
    
        override fun addTransferListener(transferListener: TransferListener) {}
    
        override fun getUri() = uri
    
        @Throws(HttpDataSourceException::class)
        override fun close() {
            try {
                val inputStream: InputStream? = this.cipherInputStream
                if (inputStream != null) {
                    val bytesRemaining =
                        if (bytesToRead == C.LENGTH_UNSET.toLong()) C.LENGTH_UNSET.toLong() else bytesToRead - bytesRead
                    maybeTerminateInputStream(connection, bytesRemaining)
                    try {
                        inputStream.close()
                    } catch (e: IOException) {
                        throw HttpDataSourceException(
                            e,
                            Util.castNonNull(updatedDataSpec),
                            PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
                            HttpDataSourceException.TYPE_CLOSE
                        )
                    }
                }
            } finally {
                cipherInputStream = null
                connectionMaker.closeConnection()
                if (isOpen) {
                    isOpen = false
                }
            }
        }
    
        private fun maybeTerminateInputStream(connection: HttpURLConnection?, bytesRemaining: Long) {
            if (connection == null || Util.SDK_INT < 19 || Util.SDK_INT > 20) {
                return
            }
            try {
                val inputStream = connection.inputStream
                if (bytesRemaining == C.LENGTH_UNSET.toLong()) {
                    // If the input stream has already ended, do nothing. The socket may be re-used.
                    if (inputStream.read() == -1) {
                        return
                    }
                } else if (bytesRemaining <= MAX_BYTES_TO_DRAIN) {
                    // There isn't much data left. Prefer to allow it to drain, which may allow the socket to be
                    // re-used.
                    return
                }
                val className = inputStream.javaClass.name
                if ("com.android.okhttp.internal.http.HttpTransport\$ChunkedInputStream" == className
                    || ("com.android.okhttp.internal.http.HttpTransport\$FixedLengthInputStream"
                            == className)
                ) {
                    val superclass: Class<in InputStream>? = inputStream.javaClass.superclass
                    val unexpectedEndOfInput =
                        Assertions.checkNotNull(superclass).getDeclaredMethod("unexpectedEndOfInput")
                    unexpectedEndOfInput.isAccessible = true
                    unexpectedEndOfInput.invoke(inputStream)
                }
            } catch (e: Exception) {
                // If an IOException then the connection didn't ever have an input stream, or it was closed
                // already. If another type of exception then something went wrong, most likely the device
                // isn't using okhttp.
                e.printStackTrace()
            }
        }
    
        class HttpConnectionMaker {
    
            private var defaultRequestProperties: HttpDataSource.RequestProperties? = null
            private var requestProperties = HttpDataSource.RequestProperties()
            private var readTimeoutMillis = DEFAULT_CONNECT_TIMEOUT_MILLIS
            private var connectTimeoutMillis = DEFAULT_READ_TIMEOUT_MILLIS
            private var allowCrossProtocolRedirects = false
            private var keepPostFor302Redirects = false
            private var connection: HttpURLConnection? = null
            private var userAgent: String? = null
    
            @Throws(IOException::class)
            fun make(dataSpec: DataSpec): HttpURLConnection {
                var url = URL(dataSpec.uri.toString())
                var httpMethod: @DataSpec.HttpMethod Int = dataSpec.httpMethod
                var httpBody = dataSpec.httpBody
                val position = dataSpec.position
                val length = dataSpec.length
                val allowGzip = dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP)
                if (!allowCrossProtocolRedirects && !keepPostFor302Redirects) {
                    // HttpURLConnection disallows cross-protocol redirects, but otherwise performs redirection
                    // automatically. This is the behavior we want, so use it.
                    return make(
                        url,
                        httpMethod,
                        httpBody,
                        position,
                        length,
                        allowGzip,  /* followRedirects= */
                        true,
                        dataSpec.httpRequestHeaders
                    )
                }
    
                // We need to handle redirects ourselves to allow cross-protocol redirects or to keep the POST
                // request method for 302.
                var redirectCount = 0
                while (redirectCount++ <= MAX_REDIRECTS) {
                    connection = make(
                        url,
                        httpMethod,
                        httpBody,
                        position,
                        length,
                        allowGzip,  /* followRedirects= */
                        false,
                        dataSpec.httpRequestHeaders
                    )
                    val responseCode = connection?.responseCode
                    val location = connection?.getHeaderField("Location")
                    if ((httpMethod == DataSpec.HTTP_METHOD_GET || httpMethod == DataSpec.HTTP_METHOD_HEAD)
                        && (responseCode == HttpURLConnection.HTTP_MULT_CHOICE
                                || responseCode == HttpURLConnection.HTTP_MOVED_PERM
                                || responseCode == HttpURLConnection.HTTP_MOVED_TEMP
                                || responseCode == HttpURLConnection.HTTP_SEE_OTHER
                                || responseCode == HTTP_STATUS_TEMPORARY_REDIRECT
                                || responseCode == HTTP_STATUS_PERMANENT_REDIRECT)
                    ) {
                        connection?.disconnect()
                        url = handleRedirect(url, location, dataSpec)
                    } else if (httpMethod == DataSpec.HTTP_METHOD_POST
                        && (responseCode == HttpURLConnection.HTTP_MULT_CHOICE
                                || responseCode == HttpURLConnection.HTTP_MOVED_PERM
                                || responseCode == HttpURLConnection.HTTP_MOVED_TEMP
                                || responseCode == HttpURLConnection.HTTP_SEE_OTHER)
                    ) {
                        connection?.disconnect()
                        val shouldKeepPost =
                            keepPostFor302Redirects && responseCode == HttpURLConnection.HTTP_MOVED_TEMP
                        if (!shouldKeepPost) {
                            // POST request follows the redirect and is transformed into a GET request.
                            httpMethod = DataSpec.HTTP_METHOD_GET
                            httpBody = null
                        }
                        url = handleRedirect(url, location, dataSpec)
                    } else {
                        return connection!!
                    }
                }
                throw HttpDataSourceException(
                    NoRouteToHostException("Too many redirects: $redirectCount"),
                    dataSpec,
                    PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
                    HttpDataSourceException.TYPE_OPEN
                )
            }
    
            @Throws(IOException::class)
            private fun make(
                url: URL,
                httpMethod: @DataSpec.HttpMethod Int,
                httpBody: ByteArray?,
                position: Long,
                length: Long,
                allowGzip: Boolean,
                followRedirects: Boolean,
                requestParameters: Map<String, String>,
            ): HttpURLConnection {
                val connection = openConnection(url)
                connection.connectTimeout = connectTimeoutMillis
                connection.readTimeout = readTimeoutMillis
                val requestHeaders: MutableMap<String, String> = HashMap()
                if (defaultRequestProperties != null) {
                    requestHeaders.putAll(defaultRequestProperties!!.snapshot)
                }
                requestHeaders.putAll(requestProperties.snapshot)
                requestHeaders.putAll(requestParameters)
                for ((key, value) in requestHeaders) {
                    connection.setRequestProperty(key, value)
                }
                //header range
                val rangeHeader = buildRangeRequestHeader(position, length)
                if (rangeHeader != null) {
                    connection.setRequestProperty(HttpHeaders.RANGE, rangeHeader)
                }
                if (userAgent != null) {
                    connection.setRequestProperty(HttpHeaders.USER_AGENT, userAgent)
                }
                connection.setRequestProperty(
                    HttpHeaders.ACCEPT_ENCODING,
                    if (allowGzip) "gzip" else "identity"
                )
                connection.instanceFollowRedirects = followRedirects
                connection.doOutput = httpBody != null
                connection.requestMethod = DataSpec.getStringForHttpMethod(httpMethod)
                if (httpBody != null) {
                    connection.setFixedLengthStreamingMode(httpBody.size)
                    connection.connect()
                    val os = connection.outputStream
                    os.write(httpBody)
                    os.close()
                } else {
                    connection.connect()
                }
                return connection
            }
    
            private fun buildRangeRequestHeader(position: Long, length: Long): String? {
                if (position == 0L && length == C.LENGTH_UNSET.toLong()) {
                    return null
                }
                val rangeValue = StringBuilder()
                rangeValue.append("bytes=")
                rangeValue.append(position)
                rangeValue.append("-")
    
                if (length != C.LENGTH_UNSET.toLong()) {
                    rangeValue.append(position + length - 1)
                }
                return rangeValue.toString()
            }
    
            @VisibleForTesting
            @Throws(IOException::class)
            fun openConnection(url: URL): HttpURLConnection {
                return url.openConnection() as HttpURLConnection
            }
    
            @Throws(HttpDataSourceException::class)
            private fun handleRedirect(originalUrl: URL, location: String?, dataSpec: DataSpec): URL {
                if (location == null) {
                    throw HttpDataSourceException(
                        "Null location redirect",
                        dataSpec,
                        PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
                        HttpDataSourceException.TYPE_OPEN
                    )
                }
                // Form the new url.
                val url: URL = try {
                    URL(originalUrl, location)
                } catch (e: MalformedURLException) {
                    throw HttpDataSourceException(
                        e,
                        dataSpec,
                        PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
                        HttpDataSourceException.TYPE_OPEN
                    )
                }
    
                // Check that the protocol of the new url is supported.
                val protocol = url.protocol
                if ("https" != protocol && "http" != protocol) {
                    throw HttpDataSourceException(
                        "Unsupported protocol redirect: $protocol",
                        dataSpec,
                        PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
                        HttpDataSourceException.TYPE_OPEN
                    )
                }
                if (!allowCrossProtocolRedirects && protocol != originalUrl.protocol) {
                    throw HttpDataSourceException(
                        "Disallowed cross-protocol redirect ("
                                + originalUrl.protocol
                                + " to "
                                + protocol
                                + ")",
                        dataSpec,
                        PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
                        HttpDataSourceException.TYPE_OPEN
                    )
                }
                return url
            }
    
            fun closeConnection() {
                if (connection != null) {
                    try {
                        connection?.disconnect()
                    } catch (e: Exception) {
                        e.printStackTrace()
                    }
                    connection = null
                }
            }
    
            companion object {
                private const val DEFAULT_CONNECT_TIMEOUT_MILLIS = 8 * 1000
    
                /** The default read timeout, in milliseconds.  */
                private const val DEFAULT_READ_TIMEOUT_MILLIS = 8 * 1000
    
                private const val MAX_REDIRECTS = 20 // Same limit as okhttp.
    
                private const val HTTP_STATUS_TEMPORARY_REDIRECT = 307
                private const val HTTP_STATUS_PERMANENT_REDIRECT = 308
            }
        }
    
        class CipherHttpInputStream(
            private val upstream: InputStream,
            cipher: Cipher,
        ) : CipherInputStream(upstream, cipher) {
    
            @Throws(IOException::class)
            override fun available(): Int {
                return upstream.available()
            }
        }
    
        companion object {
            private const val MAX_BYTES_TO_DRAIN: Long = 2048
        }
    }