fix(android): prevent Dolby Vision seek crashes

This commit is contained in:
edde746
2026-07-24 19:10:38 +02:00
parent 4cc9a35b2e
commit d6b24c3e07
7 changed files with 376 additions and 58 deletions
@@ -8,7 +8,15 @@ import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.TrackOutput
import java.io.EOFException
/** Shared whole-sample buffering protocol for TrackOutput transforms. */
/**
* Buffers transformed [TrackOutput] data until metadata identifies the exact sample range.
*
* Media3 may append multiple samples before reporting metadata, and [TrackOutput.sampleMetadata]
* identifies a sample relative to all appended bytes using `size` and `offset`. Before calling
* [transformSample], this class compacts only that sample to `inputBuffer[0 until inputLength]`.
* Bytes after the sample remain buffered for later metadata; unreferenced bytes before it are
* discarded. Extractor decorators must call [resetBufferedData] when a seek abandons pending data.
*/
abstract class BufferedTransformingTrackOutput(
protected val delegate: TrackOutput,
initialBufferSize: Int,
@@ -26,7 +34,12 @@ abstract class BufferedTransformingTrackOutput(
protected abstract val transformEnabled: Boolean
protected abstract val transformedBuffer: ByteArray
/** Returns transformed length, or a negative value to drop the sample. */
/**
* Transforms the exact sample in `inputBuffer[0 until inputLength]`.
*
* Implementations must treat [inputBuffer] as read-only. Returns the number of bytes available
* from [transformedBuffer], or a negative value to drop the sample.
*/
protected abstract fun transformSample(inputLength: Int, flags: Int): Int
init {
@@ -80,15 +93,38 @@ abstract class BufferedTransformingTrackOutput(
return
}
buffering = false
val sourceLength = inputLength
inputLength = 0
val transformedLength = transformSample(sourceLength, flags)
// Offset can describe data for one or more later samples already appended by the extractor.
if (size < 0 || offset < 0 || size > inputLength - offset) {
resetBufferedData()
return
}
val sampleEnd = inputLength - offset
val sampleStart = sampleEnd - size
if (sampleStart > 0) {
System.arraycopy(inputBuffer, sampleStart, inputBuffer, 0, size)
}
try {
val transformedLength = transformSample(size, flags)
if (transformedLength < 0) return
outputParsable.reset(transformedBuffer, transformedLength)
delegate.sampleData(outputParsable, transformedLength, TrackOutput.SAMPLE_DATA_PART_MAIN)
delegate.sampleMetadata(timeUs, flags, transformedLength, 0, cryptoData)
} finally {
if (offset > 0) {
System.arraycopy(inputBuffer, sampleEnd, inputBuffer, 0, offset)
}
inputLength = offset
buffering = offset > 0
}
}
/** Drops sample bytes buffered before an extractor seek while retaining allocated storage. */
internal fun resetBufferedData() {
inputLength = 0
buffering = false
}
private fun appendInput(source: ByteArray, length: Int) {
@@ -138,28 +138,29 @@ class DoviConvertingTrackOutput(
override fun transformSample(inputLength: Int, flags: Int): Int {
val processStartNs = System.nanoTime()
outputIsProcessed = if ((flags and C.BUFFER_FLAG_ENCRYPTED) != 0) {
if ((flags and C.BUFFER_FLAG_ENCRYPTED) != 0) {
outputIsProcessed = false
if (!loggedEncryptedSupplementalPassthrough && (flags and C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA) != 0) {
loggedEncryptedSupplementalPassthrough = true
logWarn("Encrypted supplemental sample encountered, passing raw sample")
}
false
} else {
try {
processSampleData(flags, inputLength)
true
} catch (e: Exception) {
logError("NAL processing failed, passing raw sample", e)
false
}
}
val transformedLength = if (outputIsProcessed) outputLen else inputLength
if (outputIsProcessed) {
recordSampleProcessing((System.nanoTime() - processStartNs) / 1_000L)
return if (inputLength == 0) -1 else inputLength
}
// Skip empty samples (all NALs were DV layers) — don't confuse the decoder.
return if (transformedLength == 0) -1 else transformedLength
return try {
if (!processSampleData(flags, inputLength)) {
outputIsProcessed = true
-1
} else {
outputIsProcessed = true
recordSampleProcessing((System.nanoTime() - processStartNs) / 1_000L)
if (outputLen == 0) -1 else outputLen
}
} catch (e: Exception) {
outputIsProcessed = false
logError("NAL processing failed, passing raw sample", e)
if (inputLength == 0) -1 else inputLength
}
}
/**
@@ -169,23 +170,21 @@ class DoviConvertingTrackOutput(
* [4-byte big-endian main sample size][main sample][supplemental data].
* Only the main sample contains video NALs and should be rewritten.
*/
private fun processSampleData(flags: Int, dataLen: Int) {
private fun processSampleData(flags: Int, dataLen: Int): Boolean {
if ((flags and C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA) == 0) {
processNalUnits(0, dataLen)
return
return true
}
if (dataLen < 4) {
logWarn("Bad supplemental sample: ${dataLen}B is too small")
copyRawSample(dataLen)
return
logWarn("Bad supplemental sample: ${dataLen}B is too small; dropping sample")
return false
}
val mainSampleLen = readInt32BE(inputBuffer, 0)
if (mainSampleLen < 0 || mainSampleLen > dataLen - 4) {
logWarn("Bad supplemental sample: mainLen=$mainSampleLen total=$dataLen")
copyRawSample(dataLen)
return
logWarn("Bad supplemental sample: mainLen=$mainSampleLen total=$dataLen; dropping sample")
return false
}
val supplementalLen = dataLen - 4 - mainSampleLen
@@ -200,10 +199,7 @@ class DoviConvertingTrackOutput(
processNalUnits(4, mainSampleLen)
val processedMainLen = outputLen
if (processedMainLen == 0) {
outputLen = 0
return
}
if (processedMainLen == 0) return true
ensureOutputCapacity(4 + processedMainLen + supplementalLen)
System.arraycopy(outputBuf, 0, outputBuf, 4, processedMainLen)
@@ -219,6 +215,7 @@ class DoviConvertingTrackOutput(
"supplemental=${supplementalLen}B, total=${dataLen}B -> ${outputLen}B"
)
}
return true
}
/**
@@ -610,12 +607,6 @@ class DoviConvertingTrackOutput(
((buf[offset + 2].toInt() and 0xFF) shl 8) or
(buf[offset + 3].toInt() and 0xFF)
private fun copyRawSample(dataLen: Int) {
ensureOutputCapacity(dataLen)
System.arraycopy(inputBuffer, 0, outputBuf, 0, dataLen)
outputLen = dataLen
}
private fun formatBytes(data: ByteArray, offset: Int, length: Int): String {
val end = minOf(data.size, offset + length)
if (offset >= end) return ""
@@ -19,12 +19,19 @@ class DoviExtractorOutputWrapper(
private val emitLog: ((String, String, String) -> Unit)?,
private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit
) : ExtractorOutput {
private val trackOutputs = mutableListOf<DoviConvertingTrackOutput>()
fun resetTracks() {
trackOutputs.forEach { it.resetBufferedData() }
}
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
if (type == C.TRACK_TYPE_VIDEO) {
val wrapper = DoviConvertingTrackOutput(original, dvMode, emitLog)
onVideoTrackWrapped(wrapper)
return wrapper
return DoviConvertingTrackOutput(original, dvMode, emitLog).also {
trackOutputs.add(it)
onVideoTrackWrapped(it)
}
}
return original
}
@@ -46,16 +53,22 @@ class DoviExtractorWrapper(
@Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null
private set
private var outputWrapper: DoviExtractorOutputWrapper? = null
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
override fun init(output: ExtractorOutput) {
delegate.init(DoviExtractorOutputWrapper(output, dvMode, emitLog) { doviTrackOutput = it })
val wrapper = DoviExtractorOutputWrapper(output, dvMode, emitLog) { doviTrackOutput = it }
outputWrapper = wrapper
delegate.init(wrapper)
}
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int = delegate.read(input, seekPosition)
override fun seek(position: Long, timeUs: Long) = delegate.seek(position, timeUs)
override fun seek(position: Long, timeUs: Long) {
outputWrapper?.resetTracks()
delegate.seek(position, timeUs)
}
override fun release() = delegate.release()
}
@@ -102,6 +102,7 @@ class ZlibMatroskaExtractor(
override fun seek(position: Long, timeUs: Long) {
latmOutput?.resetTracks()
zlibOutput?.resetTracks()
super.seek(position, timeUs)
}
@@ -115,18 +116,24 @@ class ZlibMatroskaExtractor(
) : ExtractorOutput {
private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null
private val trackOutputs = mutableListOf<ZlibInflatingTrackOutput>()
override fun track(id: Int, type: Int): TrackOutput {
val original = delegate.track(id, type)
val wrapper = ZlibInflatingTrackOutput(original)
lastCreatedWrapper = wrapper
return wrapper
return ZlibInflatingTrackOutput(original).also {
trackOutputs.add(it)
lastCreatedWrapper = it
}
}
fun activateLast() {
lastCreatedWrapper?.active = true
}
fun resetTracks() {
trackOutputs.forEach { it.resetBufferedData() }
}
override fun endTracks() = delegate.endTracks()
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
}
@@ -22,7 +22,7 @@ class BufferedTransformingTrackOutputTest {
output.sampleData(ParsableByteArray(byteArrayOf(1, 2)), 2, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleData(ParsableByteArray(byteArrayOf(3)), 1, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(42, C.BUFFER_FLAG_KEY_FRAME, 3, 7, null)
output.sampleMetadata(42, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null)
assertArrayEquals(byteArrayOf(2, 3, 4), delegate.bytes.toByteArray())
assertEquals(42, delegate.timeUs)
@@ -30,6 +30,65 @@ class BufferedTransformingTrackOutputTest {
assertEquals(0, delegate.sampleOffset)
}
@Test
fun metadataBoundsDiscardAbandonedBytesAndPreserveTrailingSample() {
val delegate = RecordingTrackOutput()
val output = IncrementingTrackOutput(delegate)
output.sampleData(ParsableByteArray(byteArrayOf(9, 9)), 2, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleData(ParsableByteArray(byteArrayOf(1, 2)), 2, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleData(ParsableByteArray(byteArrayOf(3, 4, 5)), 3, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(1, C.BUFFER_FLAG_KEY_FRAME, 2, 3, null)
output.sampleMetadata(2, 0, 3, 0, null)
assertArrayEquals(byteArrayOf(2, 3, 4, 5, 6), delegate.bytes.toByteArray())
assertEquals(2, delegate.metadataCount)
assertEquals(2, delegate.timeUs)
}
@Test
fun droppedSamplePreservesTrailingSample() {
val delegate = RecordingTrackOutput()
val output = IncrementingTrackOutput(delegate)
output.sampleData(ParsableByteArray(byteArrayOf(1, 2, 3)), 3, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.dropNext = true
output.sampleMetadata(1, 0, 1, 2, null)
output.sampleMetadata(2, 0, 2, 0, null)
assertArrayEquals(byteArrayOf(3, 4), delegate.bytes.toByteArray())
assertEquals(1, delegate.metadataCount)
}
@Test
fun resetDiscardsPendingBytesWithoutReleasingCapacity() {
val delegate = RecordingTrackOutput()
val output = IncrementingTrackOutput(delegate, maxBufferedSampleBytes = 4)
output.sampleData(ParsableByteArray(byteArrayOf(9, 9, 9, 9)), 4, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.resetBufferedData()
output.sampleData(ParsableByteArray(byteArrayOf(1, 2, 3, 4)), 4, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(1, 0, 4, 0, null)
assertArrayEquals(byteArrayOf(2, 3, 4, 5), delegate.bytes.toByteArray())
assertEquals(1, delegate.metadataCount)
}
@Test
fun invalidMetadataDropsBufferedBytesAndRecovers() {
val delegate = RecordingTrackOutput()
val output = IncrementingTrackOutput(delegate)
output.sampleData(ParsableByteArray(byteArrayOf(1, 2)), 2, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(1, 0, 3, 0, null)
output.sampleData(ParsableByteArray(byteArrayOf(4)), 1, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(2, 0, 1, 0, null)
assertArrayEquals(byteArrayOf(5), delegate.bytes.toByteArray())
assertEquals(1, delegate.metadataCount)
}
@Test
fun activeTransformHonorsDataReaderEndOfInputContract() {
val output = IncrementingTrackOutput(RecordingTrackOutput())
@@ -77,6 +136,7 @@ class BufferedTransformingTrackOutputTest {
initialBufferSize = 2,
maxBufferedSampleBytes = maxBufferedSampleBytes
) {
var dropNext = false
private var transformed = ByteArray(2)
override val transformEnabled = true
@@ -84,6 +144,10 @@ class BufferedTransformingTrackOutputTest {
get() = transformed
override fun transformSample(inputLength: Int, flags: Int): Int {
if (dropNext) {
dropNext = false
return -1
}
if (transformed.size < inputLength) transformed = ByteArray(inputLength)
for (index in 0 until inputLength) {
transformed[index] = (inputBuffer[index] + 1).toByte()
@@ -97,6 +161,7 @@ class BufferedTransformingTrackOutputTest {
var timeUs = C.TIME_UNSET
var sampleSize = -1
var sampleOffset = -1
var metadataCount = 0
override fun format(format: Format) = Unit
@@ -125,6 +190,7 @@ class BufferedTransformingTrackOutputTest {
offset: Int,
cryptoData: TrackOutput.CryptoData?
) {
metadataCount++
this.timeUs = timeUs
sampleSize = size
sampleOffset = offset
@@ -0,0 +1,185 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import androidx.media3.common.DataReader
import androidx.media3.common.Format
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.ParsableByteArray
import androidx.media3.extractor.Extractor
import androidx.media3.extractor.ExtractorInput
import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import java.io.ByteArrayOutputStream
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class DoviConvertingTrackOutputTest {
@Test
fun malformedSupplementalSampleIsDroppedAndNextSampleRecovers() {
val delegate = RecordingTrackOutput()
val messages = mutableListOf<String>()
val output = DoviConvertingTrackOutput(delegate, emitLog = { _, _, message -> messages.add(message) })
activateProfile7(output)
feed(output, ByteArray(3), C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA)
val malformed = ByteArray(5_882)
writeInt32Be(malformed, 0, 279_101)
feed(output, malformed, C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA)
assertEquals(0, delegate.samples.size)
assertTrue(messages.any { it.contains("mainLen=279101 total=5882; dropping sample") })
assertTrue(messages.any { it.contains("3B is too small; dropping sample") })
val valid = supplementalSample(
main = byteArrayOf(0, 0, 0, 1, 0x40, 0x01),
supplemental = byteArrayOf(0x55)
)
feed(output, valid, C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA)
assertEquals(1, delegate.samples.size)
assertArrayEquals(valid, delegate.samples.single())
}
@Test
fun repeatedExtractorSeeksDoNotLeakInterruptedSamplesIntoPlayback() {
val extractor = RecordingExtractor()
val delegateTrack = RecordingTrackOutput()
val output = RecordingExtractorOutput(delegateTrack)
val messages = mutableListOf<String>()
val wrapper = DoviExtractorWrapper(extractor, emitLog = { _, _, message -> messages.add(message) })
wrapper.init(output)
val wrappedTrack = extractor.output.track(1, C.TRACK_TYPE_VIDEO)
activateProfile7(wrappedTrack)
repeat(2) { index ->
val interrupted = ByteArray(5_882)
writeInt32Be(interrupted, 0, 279_101)
wrappedTrack.sampleData(
ParsableByteArray(interrupted),
interrupted.size,
TrackOutput.SAMPLE_DATA_PART_MAIN
)
wrapper.seek(1234L + index, 5_000L + index)
}
val valid = supplementalSample(
main = byteArrayOf(0, 0, 0, 1, 0x40, 0x01),
supplemental = byteArrayOf(0x55)
)
feed(wrappedTrack, valid, C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA)
assertEquals(2, extractor.seekCount)
assertEquals(1235L, extractor.seekPosition)
assertEquals(5_001L, extractor.seekTimeUs)
assertEquals(1, delegateTrack.samples.size)
assertArrayEquals(valid, delegateTrack.samples.single())
assertTrue(messages.none { it.startsWith("Bad supplemental sample") })
}
private fun activateProfile7(output: TrackOutput) {
output.format(
Format.Builder()
.setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION)
.setCodecs("dvhe.07.06")
.build()
)
}
private fun feed(output: TrackOutput, sample: ByteArray, flags: Int) {
output.sampleData(ParsableByteArray(sample), sample.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(1, flags, sample.size, 0, null)
}
private fun supplementalSample(main: ByteArray, supplemental: ByteArray): ByteArray {
val sample = ByteArray(4 + main.size + supplemental.size)
writeInt32Be(sample, 0, main.size)
main.copyInto(sample, destinationOffset = 4)
supplemental.copyInto(sample, destinationOffset = 4 + main.size)
return sample
}
private fun writeInt32Be(target: ByteArray, offset: Int, value: Int) {
target[offset] = ((value ushr 24) and 0xFF).toByte()
target[offset + 1] = ((value ushr 16) and 0xFF).toByte()
target[offset + 2] = ((value ushr 8) and 0xFF).toByte()
target[offset + 3] = (value and 0xFF).toByte()
}
private class RecordingTrackOutput : TrackOutput {
val samples = mutableListOf<ByteArray>()
private val pending = ByteArrayOutputStream()
override fun format(format: Format) = Unit
override fun sampleData(
input: DataReader,
length: Int,
allowEndOfInput: Boolean,
sampleDataPart: Int
): Int {
val buffer = ByteArray(length)
val read = input.read(buffer, 0, length)
if (read > 0) pending.write(buffer, 0, read)
return read
}
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
val buffer = ByteArray(length)
data.readBytes(buffer, 0, length)
pending.write(buffer)
}
override fun sampleMetadata(
timeUs: Long,
flags: Int,
size: Int,
offset: Int,
cryptoData: TrackOutput.CryptoData?
) {
samples.add(pending.toByteArray())
pending.reset()
}
}
private class RecordingExtractor : Extractor {
lateinit var output: ExtractorOutput
var seekPosition = C.INDEX_UNSET.toLong()
var seekTimeUs = C.TIME_UNSET
var seekCount = 0
override fun sniff(input: ExtractorInput): Boolean = true
override fun init(output: ExtractorOutput) {
this.output = output
}
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int = Extractor.RESULT_END_OF_INPUT
override fun seek(position: Long, timeUs: Long) {
seekCount++
this.seekPosition = position
seekTimeUs = timeUs
}
override fun release() = Unit
}
private class RecordingExtractorOutput(
private val trackOutput: TrackOutput
) : ExtractorOutput {
override fun track(id: Int, type: Int): TrackOutput = trackOutput
override fun endTracks() = Unit
override fun seekMap(seekMap: SeekMap) = Unit
}
}
@@ -35,7 +35,7 @@ class ZlibInflatingTrackOutputTest {
compressed.size - split,
TrackOutput.SAMPLE_DATA_PART_MAIN
)
output.sampleMetadata(42L, C.BUFFER_FLAG_KEY_FRAME, compressed.size, 7, null)
output.sampleMetadata(42L, C.BUFFER_FLAG_KEY_FRAME, compressed.size, 0, null)
assertArrayEquals(original, delegate.retained.toByteArray())
assertEquals(42L, delegate.timeUs)
@@ -45,6 +45,24 @@ class ZlibInflatingTrackOutputTest {
assertEquals(1, delegate.metadataCount)
}
@Test
fun activeTransformUsesMetadataBoundariesForBufferedSamples() {
val first = ByteArray(4096) { (it % 19).toByte() }
val second = ByteArray(2048) { (it % 23).toByte() }
val firstCompressed = deflate(first)
val secondCompressed = deflate(second)
val combined = firstCompressed + secondCompressed
val delegate = RecordingTrackOutput(retainBytes = true)
val output = ZlibInflatingTrackOutput(delegate).apply { active = true }
output.sampleData(ParsableByteArray(combined), combined.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(1, 0, firstCompressed.size, secondCompressed.size, null)
output.sampleMetadata(2, 0, secondCompressed.size, 0, null)
assertArrayEquals(first + second, delegate.retained.toByteArray())
assertEquals(2, delegate.metadataCount)
}
@Test
fun exactlySixteenMiBInflatedSampleIsAccepted() {
val size = 16 * 1024 * 1024
@@ -120,15 +138,17 @@ class ZlibInflatingTrackOutputTest {
@Test
fun inactiveWrapperDelegatesBytesAndMetadataUnchanged() {
val bytes = byteArrayOf(9, 8, 7, 6)
val trailing = byteArrayOf(5, 4)
val allBytes = bytes + trailing
val delegate = RecordingTrackOutput(retainBytes = true)
val output = ZlibInflatingTrackOutput(delegate)
output.sampleData(ParsableByteArray(bytes), bytes.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(99L, 3, bytes.size, 2, null)
output.sampleData(ParsableByteArray(allBytes), allBytes.size, TrackOutput.SAMPLE_DATA_PART_MAIN)
output.sampleMetadata(99L, 3, bytes.size, trailing.size, null)
assertArrayEquals(bytes, delegate.retained.toByteArray())
assertArrayEquals(allBytes, delegate.retained.toByteArray())
assertEquals(bytes.size, delegate.sampleSize)
assertEquals(2, delegate.offset)
assertEquals(trailing.size, delegate.offset)
assertEquals(99L, delegate.timeUs)
}