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
相关产品推荐
相关产品推荐

