Android TV: F1TV cbcs-samples zelf ontsleutelen (ClearKey kan alleen AES-CTR).
This commit is contained in:
parent
e89ba46eb8
commit
a5ee125fb2
3 changed files with 352 additions and 1 deletions
|
|
@ -559,6 +559,7 @@ class ApiClient(
|
|||
DrmInfo(
|
||||
type = drmObj.optString("type").ifBlank { "clearkey" },
|
||||
keys = keys,
|
||||
scheme = drmObj.optString("scheme").ifBlank { null },
|
||||
).takeIf { it.keys.isNotEmpty() }
|
||||
} else null
|
||||
IptvPlayInfo(
|
||||
|
|
@ -860,6 +861,7 @@ class ApiClient(
|
|||
DrmInfo(
|
||||
type = drmObj.optString("type").ifBlank { "clearkey" },
|
||||
keys = keys,
|
||||
scheme = drmObj.optString("scheme").ifBlank { null },
|
||||
).takeIf { it.keys.isNotEmpty() }
|
||||
} else null
|
||||
return IptvPlayInfo(
|
||||
|
|
@ -1619,6 +1621,8 @@ data class ClearKeyEntry(
|
|||
data class DrmInfo(
|
||||
val type: String,
|
||||
val keys: List<ClearKeyEntry>,
|
||||
/** "cbcs" = app ontsleutelt zelf (Android ClearKey kan alleen AES-CTR). */
|
||||
val scheme: String? = null,
|
||||
)
|
||||
|
||||
data class IptvPlayInfo(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,286 @@
|
|||
package nl.vonas.mediacluster.tv.playback
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DataReader
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.common.util.TimestampAdjuster
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import android.os.Looper
|
||||
import androidx.media3.exoplayer.analytics.PlayerId
|
||||
import androidx.media3.exoplayer.drm.DrmSession
|
||||
import androidx.media3.exoplayer.drm.DrmSessionEventListener
|
||||
import androidx.media3.exoplayer.drm.DrmSessionManager
|
||||
import androidx.media3.exoplayer.hls.DefaultHlsExtractorFactory
|
||||
import androidx.media3.exoplayer.hls.HlsExtractorFactory
|
||||
import androidx.media3.exoplayer.hls.HlsMediaChunkExtractor
|
||||
import androidx.media3.extractor.ExtractorInput
|
||||
import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import androidx.media3.extractor.text.SubtitleParser
|
||||
import java.io.EOFException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* Software-ontsleuteling van cbcs (AES-CBC patroon) samples tussen de fMP4-extractor en de decoder.
|
||||
* Android's ClearKey-plugin ondersteunt alleen AES-CTR; F1TV levert HLS cbcs (SAMPLE-AES).
|
||||
* De stream komt rechtstreeks van de CDN; alleen de sleutels komen van de server.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class CbcsDecryptingHlsExtractorFactory(
|
||||
private val keysByKid: Map<String, ByteArray>,
|
||||
private val delegate: HlsExtractorFactory = DefaultHlsExtractorFactory(),
|
||||
) : HlsExtractorFactory {
|
||||
override fun createExtractor(
|
||||
uri: Uri,
|
||||
format: Format,
|
||||
muxedCaptionFormats: List<Format>?,
|
||||
timestampAdjuster: TimestampAdjuster,
|
||||
responseHeaders: Map<String, List<String>>,
|
||||
sniffingExtractorInput: ExtractorInput,
|
||||
playerId: PlayerId,
|
||||
): HlsMediaChunkExtractor = CbcsDecryptingChunkExtractor(
|
||||
delegate.createExtractor(
|
||||
uri,
|
||||
format,
|
||||
muxedCaptionFormats,
|
||||
timestampAdjuster,
|
||||
responseHeaders,
|
||||
sniffingExtractorInput,
|
||||
playerId,
|
||||
),
|
||||
keysByKid,
|
||||
)
|
||||
|
||||
override fun setSubtitleParserFactory(subtitleParserFactory: SubtitleParser.Factory): HlsExtractorFactory {
|
||||
delegate.setSubtitleParserFactory(subtitleParserFactory)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun experimentalParseSubtitlesDuringExtraction(parseSubtitlesDuringExtraction: Boolean): HlsExtractorFactory {
|
||||
delegate.experimentalParseSubtitlesDuringExtraction(parseSubtitlesDuringExtraction)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun experimentalSetCodecsToParseWithinGopSampleDependencies(codecsToParseWithinGopSampleDependencies: Int): HlsExtractorFactory {
|
||||
delegate.experimentalSetCodecsToParseWithinGopSampleDependencies(codecsToParseWithinGopSampleDependencies)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun getOutputTextFormat(sourceFormat: Format): Format = delegate.getOutputTextFormat(sourceFormat)
|
||||
}
|
||||
|
||||
/**
|
||||
* Playlist-`EXT-X-KEY` (Widevine KEYFORMAT) zet DrmInitData op de HLS-tracks; de samples zijn
|
||||
* na [CbcsDecryptingTrackOutput] al clear, dus geen DRM-sessie en crypto-type NONE.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
object CbcsClearDrmSessionManager : DrmSessionManager {
|
||||
override fun setPlayer(playbackLooper: Looper, playerId: PlayerId) = Unit
|
||||
override fun acquireSession(eventDispatcher: DrmSessionEventListener.EventDispatcher?, format: Format): DrmSession? = null
|
||||
override fun getCryptoType(format: Format): Int = C.CRYPTO_TYPE_NONE
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private class CbcsDecryptingChunkExtractor(
|
||||
private val delegate: HlsMediaChunkExtractor,
|
||||
private val keysByKid: Map<String, ByteArray>,
|
||||
) : HlsMediaChunkExtractor {
|
||||
override fun init(extractorOutput: ExtractorOutput) {
|
||||
delegate.init(CbcsDecryptingExtractorOutput(extractorOutput, keysByKid))
|
||||
}
|
||||
|
||||
override fun read(extractorInput: ExtractorInput): Boolean = delegate.read(extractorInput)
|
||||
override fun isPackedAudioExtractor(): Boolean = delegate.isPackedAudioExtractor
|
||||
override fun isReusable(): Boolean = delegate.isReusable
|
||||
override fun recreate(): HlsMediaChunkExtractor = CbcsDecryptingChunkExtractor(delegate.recreate(), keysByKid)
|
||||
override fun onTruncatedSegmentParsed() = delegate.onTruncatedSegmentParsed()
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private class CbcsDecryptingExtractorOutput(
|
||||
private val delegate: ExtractorOutput,
|
||||
private val keysByKid: Map<String, ByteArray>,
|
||||
) : ExtractorOutput {
|
||||
private val tracks = HashMap<Int, CbcsDecryptingTrackOutput>()
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput =
|
||||
tracks.getOrPut(id) { CbcsDecryptingTrackOutput(delegate.track(id, type), keysByKid) }
|
||||
|
||||
override fun endTracks() = delegate.endTracks()
|
||||
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffert de bytes van één sample (encryptie-header + data zoals de extractor ze schrijft),
|
||||
* ontsleutelt bij [sampleMetadata] en geeft een clear sample door zonder CryptoData.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
private class CbcsDecryptingTrackOutput(
|
||||
private val delegate: TrackOutput,
|
||||
private val keysByKid: Map<String, ByteArray>,
|
||||
) : TrackOutput {
|
||||
private var buffer = ByteArray(256 * 1024)
|
||||
private var pending = 0
|
||||
private val out = ParsableByteArray()
|
||||
private val cipher: Cipher = Cipher.getInstance("AES/CBC/NoPadding")
|
||||
private val block = ByteArray(16)
|
||||
private val iv = ByteArray(16)
|
||||
private var cachedKid: ByteArray? = null
|
||||
private var cachedKey: SecretKeySpec? = null
|
||||
|
||||
override fun format(format: Format) {
|
||||
delegate.format(
|
||||
if (format.drmInitData != null) format.buildUpon().setDrmInitData(null).build() else format,
|
||||
)
|
||||
}
|
||||
|
||||
override fun durationUs(durationUs: Long) = delegate.durationUs(durationUs)
|
||||
|
||||
override fun sampleData(input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int): Int {
|
||||
ensureCapacity(pending + length)
|
||||
val read = input.read(buffer, pending, length)
|
||||
if (read == C.RESULT_END_OF_INPUT) {
|
||||
if (allowEndOfInput) return C.RESULT_END_OF_INPUT
|
||||
throw EOFException()
|
||||
}
|
||||
pending += read
|
||||
return read
|
||||
}
|
||||
|
||||
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
|
||||
ensureCapacity(pending + length)
|
||||
data.readBytes(buffer, pending, length)
|
||||
pending += length
|
||||
}
|
||||
|
||||
override fun sampleMetadata(
|
||||
timeUs: Long,
|
||||
flags: Int,
|
||||
size: Int,
|
||||
offset: Int,
|
||||
cryptoData: TrackOutput.CryptoData?,
|
||||
) {
|
||||
val start = pending - offset - size
|
||||
check(start >= 0) { "cbcs: sample-buffer inconsistent (pending=$pending size=$size offset=$offset)" }
|
||||
val end = start + size
|
||||
var dataStart = start
|
||||
var outFlags = flags
|
||||
if (flags and C.BUFFER_FLAG_ENCRYPTED != 0 && cryptoData != null) {
|
||||
dataStart = decryptSample(start, end, flags, cryptoData)
|
||||
outFlags = flags and C.BUFFER_FLAG_ENCRYPTED.inv()
|
||||
}
|
||||
val outSize = end - dataStart
|
||||
out.reset(buffer, end)
|
||||
out.setPosition(dataStart)
|
||||
delegate.sampleData(out, outSize, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||
delegate.sampleMetadata(timeUs, outFlags, outSize, 0, null)
|
||||
if (offset > 0) System.arraycopy(buffer, end, buffer, 0, offset)
|
||||
pending = offset
|
||||
}
|
||||
|
||||
/** Ontsleutelt in-place; geeft de positie terug waar de (clear) sample-data begint. */
|
||||
private fun decryptSample(start: Int, end: Int, flags: Int, crypto: TrackOutput.CryptoData): Int {
|
||||
check(crypto.cryptoMode == C.CRYPTO_MODE_AES_CBC) { "cbcs: niet-ondersteunde crypto-mode ${crypto.cryptoMode}" }
|
||||
var p = start
|
||||
val signal = buffer[p++].toInt() and 0xFF
|
||||
val ivSize = signal and 0x7F
|
||||
val subsampleEncryption = signal and 0x80 != 0
|
||||
iv.fill(0)
|
||||
System.arraycopy(buffer, p, iv, 0, minOf(ivSize, 16))
|
||||
p += ivSize
|
||||
|
||||
var subsampleCount = 0
|
||||
var subsampleTable = 0
|
||||
if (subsampleEncryption) {
|
||||
subsampleCount = readU16(p)
|
||||
p += 2
|
||||
subsampleTable = p
|
||||
p += subsampleCount * 6
|
||||
}
|
||||
if (flags and C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA != 0) {
|
||||
// Encryptie-header → [4 byte lengte][supplemental] → hoofddata; alleen hoofddata is versleuteld.
|
||||
val supplementalSize = readI32(p)
|
||||
val dataStart = p
|
||||
decryptRanges(p + 4 + supplementalSize, end, subsampleEncryption, subsampleCount, subsampleTable, crypto)
|
||||
return dataStart
|
||||
}
|
||||
decryptRanges(p, end, subsampleEncryption, subsampleCount, subsampleTable, crypto)
|
||||
return p
|
||||
}
|
||||
|
||||
private fun decryptRanges(
|
||||
dataStart: Int,
|
||||
end: Int,
|
||||
subsampleEncryption: Boolean,
|
||||
subsampleCount: Int,
|
||||
subsampleTable: Int,
|
||||
crypto: TrackOutput.CryptoData,
|
||||
) {
|
||||
val key = keyFor(crypto.encryptionKey)
|
||||
if (!subsampleEncryption) {
|
||||
decryptPattern(dataStart, end - dataStart, key, crypto)
|
||||
return
|
||||
}
|
||||
var pos = dataStart
|
||||
for (i in 0 until subsampleCount) {
|
||||
val entry = subsampleTable + i * 6
|
||||
pos += readU16(entry)
|
||||
val encrypted = readI32(entry + 2)
|
||||
check(pos + encrypted <= end) { "cbcs: subsample buiten sample" }
|
||||
if (encrypted > 0) decryptPattern(pos, encrypted, key, crypto)
|
||||
pos += encrypted
|
||||
}
|
||||
}
|
||||
|
||||
/** Eén beschermd bereik: IV reset, patroon crypt:skip in 16-byte blokken (0:0 = alle blokken). */
|
||||
private fun decryptPattern(offset: Int, length: Int, key: SecretKeySpec, crypto: TrackOutput.CryptoData) {
|
||||
val blocks = length / 16
|
||||
if (blocks == 0) return
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
|
||||
var crypt = crypto.encryptedBlocks
|
||||
var skip = crypto.clearBlocks
|
||||
if (crypt == 0 && skip == 0) {
|
||||
crypt = blocks
|
||||
}
|
||||
var b = 0
|
||||
while (b < blocks) {
|
||||
val n = minOf(crypt, blocks - b)
|
||||
for (k in 0 until n) {
|
||||
val at = offset + (b + k) * 16
|
||||
cipher.update(buffer, at, 16, block, 0)
|
||||
System.arraycopy(block, 0, buffer, at, 16)
|
||||
}
|
||||
b += n + skip
|
||||
}
|
||||
}
|
||||
|
||||
private fun keyFor(kid: ByteArray): SecretKeySpec {
|
||||
cachedKey?.let { if (cachedKid.contentEquals(kid)) return it }
|
||||
val hex = kid.joinToString("") { "%02x".format(it.toInt() and 0xFF) }
|
||||
val raw = keysByKid[hex] ?: error("cbcs: geen sleutel voor KID $hex")
|
||||
return SecretKeySpec(raw, "AES").also {
|
||||
cachedKid = kid.copyOf()
|
||||
cachedKey = it
|
||||
}
|
||||
}
|
||||
|
||||
private fun readU16(p: Int): Int =
|
||||
((buffer[p].toInt() and 0xFF) shl 8) or (buffer[p + 1].toInt() and 0xFF)
|
||||
|
||||
private fun readI32(p: Int): Int =
|
||||
((buffer[p].toInt() and 0xFF) shl 24) or ((buffer[p + 1].toInt() and 0xFF) shl 16) or
|
||||
((buffer[p + 2].toInt() and 0xFF) shl 8) or (buffer[p + 3].toInt() and 0xFF)
|
||||
|
||||
private fun ensureCapacity(needed: Int) {
|
||||
if (needed <= buffer.size) return
|
||||
var n = buffer.size
|
||||
while (n < needed) n *= 2
|
||||
buffer = buffer.copyOf(n)
|
||||
}
|
||||
}
|
||||
|
|
@ -31,7 +31,12 @@ import androidx.media3.extractor.mp4.PsshAtomUtil
|
|||
import android.os.Looper
|
||||
import java.nio.ByteBuffer
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
|
||||
|
|
@ -234,10 +239,11 @@ fun buildLiveTvPlayer(context: Context): ExoPlayer {
|
|||
.build()
|
||||
|
||||
val fallbackDrmProvider = DefaultDrmSessionManagerProvider()
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
|
||||
val defaultMediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
|
||||
.setDrmSessionManagerProvider { mediaItem ->
|
||||
clearKeyDrmSessionManagerFor(mediaItem) ?: fallbackDrmProvider.get(mediaItem)
|
||||
}
|
||||
val mediaSourceFactory = CbcsAwareMediaSourceFactory(defaultMediaSourceFactory, dataSourceFactory)
|
||||
|
||||
return ExoPlayer.Builder(appContext, renderersFactory)
|
||||
.setTrackSelector(trackSelector)
|
||||
|
|
@ -259,6 +265,42 @@ private data class ClearKeyMaterial(
|
|||
private val clearKeyLicenses = ConcurrentHashMap<String, ClearKeyMaterial>()
|
||||
private const val TAG = "VmcLiveDrm"
|
||||
|
||||
/** cbcs-sleutels (kid hex → key) per MediaItem.mediaId; zie [CbcsDecryptingHlsExtractorFactory]. */
|
||||
private val cbcsKeys = ConcurrentHashMap<String, Map<String, ByteArray>>()
|
||||
|
||||
/** HLS cbcs-items → eigen HlsMediaSource met software-ontsleuteling; al het andere ongewijzigd. */
|
||||
@OptIn(UnstableApi::class)
|
||||
private class CbcsAwareMediaSourceFactory(
|
||||
private val default: MediaSource.Factory,
|
||||
private val dataSourceFactory: DataSource.Factory,
|
||||
) : MediaSource.Factory {
|
||||
private var loadErrorHandlingPolicy: LoadErrorHandlingPolicy? = null
|
||||
|
||||
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
|
||||
default.setDrmSessionManagerProvider(drmSessionManagerProvider)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
|
||||
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy
|
||||
default.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun getSupportedTypes(): IntArray = default.supportedTypes
|
||||
|
||||
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
|
||||
val keys = mediaItem.mediaId.takeIf { it.isNotBlank() }?.let { cbcsKeys[it] }
|
||||
?: return default.createMediaSource(mediaItem)
|
||||
Log.i(TAG, "cbcs software-decrypt mediaId=${mediaItem.mediaId} kids=${keys.size}")
|
||||
return HlsMediaSource.Factory(dataSourceFactory)
|
||||
.setExtractorFactory(CbcsDecryptingHlsExtractorFactory(keys))
|
||||
.setDrmSessionManagerProvider { CbcsClearDrmSessionManager }
|
||||
.also { f -> loadErrorHandlingPolicy?.let { f.setLoadErrorHandlingPolicy(it) } }
|
||||
.createMediaSource(mediaItem)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun clearKeyDrmSessionManagerFor(mediaItem: MediaItem): DrmSessionManager? {
|
||||
val mediaId = mediaItem.mediaId?.takeIf { it.isNotBlank() } ?: return null
|
||||
|
|
@ -382,6 +424,23 @@ fun liveMediaItem(
|
|||
)
|
||||
}
|
||||
val clearKeys = drm?.takeIf { it.type.equals("clearkey", ignoreCase = true) }?.keys.orEmpty()
|
||||
if (clearKeys.isNotEmpty() && isHls && drm?.scheme.equals("cbcs", ignoreCase = true)) {
|
||||
// Android ClearKey kan geen AES-CBC (cbcs) → zelf ontsleutelen, zonder DrmConfiguration.
|
||||
val mediaId = "cbcs-${UUID.randomUUID()}"
|
||||
val keyMap = HashMap<String, ByteArray>()
|
||||
for (k in clearKeys) {
|
||||
val kid = hexToBytes(k.kid)
|
||||
val key = hexToBytes(k.key)
|
||||
keyMap[kid.toHex()] = key
|
||||
keyMap[swapUuidEndianness(kid).toHex()] = key
|
||||
}
|
||||
cbcsKeys[mediaId] = keyMap
|
||||
if (cbcsKeys.size > 32) {
|
||||
cbcsKeys.keys.take(cbcsKeys.size - 24).forEach { cbcsKeys.remove(it) }
|
||||
}
|
||||
Log.i(TAG, "cbcs mediaItem keys=${clearKeys.size} url=${url.take(96)}")
|
||||
return builder.setMediaId(mediaId).build()
|
||||
}
|
||||
if (clearKeys.isNotEmpty()) {
|
||||
val mediaId = "ck-${UUID.randomUUID()}"
|
||||
val keyIds = buildList {
|
||||
|
|
@ -447,6 +506,8 @@ private fun hexToBytes(hex: String): ByteArray {
|
|||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it.toInt() and 0xFF) }
|
||||
|
||||
/** Microsoft GUID / sommige ClearKey packs: DWORD/WORD little-endian in eerste 8 bytes. */
|
||||
private fun swapUuidEndianness(b: ByteArray): ByteArray {
|
||||
require(b.size == 16)
|
||||
|
|
|
|||
Loading…
Reference in a new issue