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(
|
DrmInfo(
|
||||||
type = drmObj.optString("type").ifBlank { "clearkey" },
|
type = drmObj.optString("type").ifBlank { "clearkey" },
|
||||||
keys = keys,
|
keys = keys,
|
||||||
|
scheme = drmObj.optString("scheme").ifBlank { null },
|
||||||
).takeIf { it.keys.isNotEmpty() }
|
).takeIf { it.keys.isNotEmpty() }
|
||||||
} else null
|
} else null
|
||||||
IptvPlayInfo(
|
IptvPlayInfo(
|
||||||
|
|
@ -860,6 +861,7 @@ class ApiClient(
|
||||||
DrmInfo(
|
DrmInfo(
|
||||||
type = drmObj.optString("type").ifBlank { "clearkey" },
|
type = drmObj.optString("type").ifBlank { "clearkey" },
|
||||||
keys = keys,
|
keys = keys,
|
||||||
|
scheme = drmObj.optString("scheme").ifBlank { null },
|
||||||
).takeIf { it.keys.isNotEmpty() }
|
).takeIf { it.keys.isNotEmpty() }
|
||||||
} else null
|
} else null
|
||||||
return IptvPlayInfo(
|
return IptvPlayInfo(
|
||||||
|
|
@ -1619,6 +1621,8 @@ data class ClearKeyEntry(
|
||||||
data class DrmInfo(
|
data class DrmInfo(
|
||||||
val type: String,
|
val type: String,
|
||||||
val keys: List<ClearKeyEntry>,
|
val keys: List<ClearKeyEntry>,
|
||||||
|
/** "cbcs" = app ontsleutelt zelf (Android ClearKey kan alleen AES-CTR). */
|
||||||
|
val scheme: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class IptvPlayInfo(
|
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 android.os.Looper
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
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.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.exoplayer.source.MediaSource
|
||||||
|
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||||
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
|
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
|
||||||
|
|
@ -234,10 +239,11 @@ fun buildLiveTvPlayer(context: Context): ExoPlayer {
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val fallbackDrmProvider = DefaultDrmSessionManagerProvider()
|
val fallbackDrmProvider = DefaultDrmSessionManagerProvider()
|
||||||
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
|
val defaultMediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
|
||||||
.setDrmSessionManagerProvider { mediaItem ->
|
.setDrmSessionManagerProvider { mediaItem ->
|
||||||
clearKeyDrmSessionManagerFor(mediaItem) ?: fallbackDrmProvider.get(mediaItem)
|
clearKeyDrmSessionManagerFor(mediaItem) ?: fallbackDrmProvider.get(mediaItem)
|
||||||
}
|
}
|
||||||
|
val mediaSourceFactory = CbcsAwareMediaSourceFactory(defaultMediaSourceFactory, dataSourceFactory)
|
||||||
|
|
||||||
return ExoPlayer.Builder(appContext, renderersFactory)
|
return ExoPlayer.Builder(appContext, renderersFactory)
|
||||||
.setTrackSelector(trackSelector)
|
.setTrackSelector(trackSelector)
|
||||||
|
|
@ -259,6 +265,42 @@ private data class ClearKeyMaterial(
|
||||||
private val clearKeyLicenses = ConcurrentHashMap<String, ClearKeyMaterial>()
|
private val clearKeyLicenses = ConcurrentHashMap<String, ClearKeyMaterial>()
|
||||||
private const val TAG = "VmcLiveDrm"
|
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)
|
@OptIn(UnstableApi::class)
|
||||||
private fun clearKeyDrmSessionManagerFor(mediaItem: MediaItem): DrmSessionManager? {
|
private fun clearKeyDrmSessionManagerFor(mediaItem: MediaItem): DrmSessionManager? {
|
||||||
val mediaId = mediaItem.mediaId?.takeIf { it.isNotBlank() } ?: return null
|
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()
|
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()) {
|
if (clearKeys.isNotEmpty()) {
|
||||||
val mediaId = "ck-${UUID.randomUUID()}"
|
val mediaId = "ck-${UUID.randomUUID()}"
|
||||||
val keyIds = buildList {
|
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. */
|
/** Microsoft GUID / sommige ClearKey packs: DWORD/WORD little-endian in eerste 8 bytes. */
|
||||||
private fun swapUuidEndianness(b: ByteArray): ByteArray {
|
private fun swapUuidEndianness(b: ByteArray): ByteArray {
|
||||||
require(b.size == 16)
|
require(b.size == 16)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue