You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

ExoPlayer播放加密远程音频Seek异常,如何实现skip方法?

ExoPlayer加密音频Seek失效:重写CipherInputStream的skip方法解决方案

我需要用ExoPlayer流式播放加密的远程音频文件,已经实现了自定义DataSource处理read()和open()方法。从起始位置(position=0)播放正常,但从其他位置开始或执行Seek操作时,密码器失效导致文件截断,引发ExoPlayer错误。经排查,需要针对加密算法重写CipherInputStream的skip()方法,请问该如何实现?


现有自定义DataSource类:HttpCipherEncryptedDataSource

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

        // 建立服务器连接
        connection = connectionMaker.make(dataSpec)
        val responseCode = connection!!.responseCode
        val responseMessage = connection!!.responseMessage

        // 检查响应码是否有效
        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
            )
        }

        // 计算需要跳过的字节数
        val bytesToSkip =
            if (responseCode == 200 && dataSpec.position != 0L) dataSpec.position else 0

        // 计算跳过之后需要读取的数据长度
        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时,响应中的Content-Length是压缩后的数据长度,所以直接使用dataSpec中的长度
            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()) {
                // 如果输入流已经结束,不做处理,socket可以复用
                if (inputStream.read() == -1) {
                    return
                }
            } else if (bytesRemaining <= MAX_BYTES_TO_DRAIN) {
                // 剩余数据不多,让它自然消耗,socket可以复用
                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) {
            // 如果是IOException,说明连接没有输入流或者已经关闭;其他异常则可能设备没使用okhttp
            e.printStackTrace()
        }
    }

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

现有CipherHttpInputStream类

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()
    }

}

解决方案:重写CipherInputStream的skip方法

问题根源在于CipherInputStream默认的skip方法实现不兼容块加密算法(比如你用的AES/ECB/PCSK5Padding),直接跳过字节会破坏密码器的块对齐状态,导致后续解密失败。同时当前的forceSkip直接读取上游字节但未经过密码器处理,会让密码器的内部状态和实际解密进度不一致。

修改后的CipherHttpInputStream类

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

    private val MAX_SKIP_BUFFER_SIZE = 2048
    // 记录密码器已处理的字节数,用于维护块对齐状态
    private var processedBytes = 0L

    init {
        initCipher()
    }

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

    override fun skip(n: Long): Long {
        if (n <= 0) return 0

        var remaining = n
        val buffer = ByteArray(MAX_SKIP_BUFFER_SIZE)
        var totalSkipped = 0L

        while (remaining > 0) {
            val readSize = Math.min(remaining, MAX_SKIP_BUFFER_SIZE.toLong()).toInt()
            val readCount = upstream.read(buffer, 0, readSize)

            if (readCount == -1) break

            // 必须让读取的加密字节经过密码器处理,维持块对齐状态
            cipher.update(buffer, 0, readCount)
            processedBytes += readCount
            totalSkipped += readCount
            remaining -= readCount
        }

        return totalSkipped
    }

    override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
        val readCount = super.read(buffer, offset, length)
        if (readCount > 0) {
            processedBytes += readCount
        }
        return readCount
    }

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

    // 统一处理Seek时的密码器重置与位置跳转
    fun seekTo(position: Long) {
        // 重置密码器状态
        initCipher()
        processedBytes = 0
        // 跳转到目标位置
        skip(position)
    }
}

修改HttpCipherEncryptedDataSource的open方法

将原有的forceSkip调用替换为新的seekTo方法:

// 原代码:
// cipherInputStream?.forceSkip(dataSpec.position)
// 替换为:
cipherInputStream?.seekTo(dataSpec.position)

关键说明

  • 块对齐维护:AES是块加密算法(默认块大小16字节),跳过字节时必须让密码器处理对应数量的加密字节,否则密码器的内部块状态会错乱,后续解密内容全部错误。
  • 密码器重置:每次Seek时必须重新初始化密码器,避免之前的块状态影响新的解密流程。
  • 统一跳转逻辑:用seekTo方法整合密码器重置和位置跳转,确保状态一致性。

内容的提问来源于stack exchange,提问作者Mason M. Doulabi

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.07 21:17:02