diff --git a/app/src/main/java/com/tpstreams/player/MainActivity.kt b/app/src/main/java/com/tpstreams/player/MainActivity.kt index 18658a78..6b808721 100644 --- a/app/src/main/java/com/tpstreams/player/MainActivity.kt +++ b/app/src/main/java/com/tpstreams/player/MainActivity.kt @@ -76,11 +76,11 @@ class MainActivity : AppCompatActivity() { } private fun initTPStreams() { - TPStreamsSDK.init("9q94nm", TPStreamsSDK.Provider.TPStreams) + TPStreamsSDK.init("9q94nm", TPStreamsSDK.Provider.TPStreams, allowFallbackToL3 = true) } private fun initTestpress() { - TestpressSDK.init("lmsdemo") + TestpressSDK.init("lmsdemo", allowFallbackToL3 = true) } private fun setupDownloadsList() { diff --git a/app/src/testApk/java/com/tpstreams/player/TestPlayerActivity.kt b/app/src/testApk/java/com/tpstreams/player/TestPlayerActivity.kt index 124e0179..59d0fe8b 100644 --- a/app/src/testApk/java/com/tpstreams/player/TestPlayerActivity.kt +++ b/app/src/testApk/java/com/tpstreams/player/TestPlayerActivity.kt @@ -24,7 +24,7 @@ class TestPlayerActivity : AppCompatActivity() { scope.setTag("is_test_apk", "true") } - TPStreamsSDK.init("9q94nm", TPStreamsSDK.Provider.TPStreams) + TPStreamsSDK.init("9q94nm", TPStreamsSDK.Provider.TPStreams, allowFallbackToL3 = true) binding.btnDrm.setOnClickListener { launchPlayer("42h2tZ5fmNf", "9327e2d0-fa13-4288-902d-840f32cd0eed") @@ -76,7 +76,7 @@ class TestPlayerActivity : AppCompatActivity() { Toast.makeText(this, "All fields are required", Toast.LENGTH_SHORT).show() return@setPositiveButton } - TPStreamsSDK.init(orgId) + TPStreamsSDK.init(orgId, allowFallbackToL3 = true) launchPlayer(assetId, accessToken) } .setNegativeButton("Cancel", null) diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/TPStreamsPlayer.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/TPStreamsPlayer.kt index f5f7c7fb..2092f25b 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/TPStreamsPlayer.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/TPStreamsPlayer.kt @@ -1,7 +1,6 @@ package com.tpstreams.player import android.content.Context -import android.media.MediaCodec import android.util.Log import androidx.annotation.OptIn import androidx.media3.common.AudioAttributes @@ -55,6 +54,8 @@ import com.tpstreams.player.util.PlaybackHistoryManager import com.tpstreams.player.util.CodecManager import com.tpstreams.player.util.ServerDateHeaderInterceptor import com.tpstreams.player.util.DecoderInfoProvider +import com.tpstreams.player.util.WidevineDrmSessionManagerProvider +import com.tpstreams.player.util.WidevinePlaybackLevelResolver import com.tpstreams.player.data.PlayerDecoderState import io.sentry.Breadcrumb import io.sentry.Sentry @@ -106,6 +107,15 @@ private constructor( private var isPrepared = false private var drmLicenseUrl: String? = null + /** + * The native Widevine security level for this device, cached once for the session. + * Used for Sentry enrichment on every DRM error. + * Sourced from [WidevinePlaybackLevelResolver] which reads it via MediaDrm at init. + */ + private val drmSecurityLevel: String by lazy { + WidevinePlaybackLevelResolver.getNativeWidevineLevel() ?: "unknown" + } + /** * True when the current asset is DRM-protected. * @@ -119,6 +129,18 @@ private constructor( */ val isDrmContent: Boolean get() = drmLicenseUrl != null || currentMediaItem?.localConfiguration?.drmConfiguration != null + + /** DRM license URL for the current asset, or null when content is not DRM-protected / not yet prepared. */ + fun getDrmLicenseUrl(): String? = drmLicenseUrl + + /** Resolved Widevine playback level actually used for DRM (`L1` or `L3`). */ + fun getDrmPlaybackLevel(): String = + if (WidevinePlaybackLevelResolver.shouldUseL3Drm()) "L3" else "L1" + + /** Device-reported native Widevine security level, or null if unavailable. */ + fun getNativeWidevineLevel(): String? = + WidevinePlaybackLevelResolver.getNativeWidevineLevel() + private var requestedPlay = false private var hasSeekedToStartAt = false private var subtitleMetadata = mapOf() @@ -130,6 +152,9 @@ private constructor( @Volatile private var released = false + @Volatile + private var l3FallbackAttempted = false + internal var onLiveStreamStatusChanged: ((Boolean) -> Unit)? = null private val playerScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) @@ -234,6 +259,10 @@ private constructor( } init { + WidevinePlaybackLevelResolver.initialize( + context.applicationContext, + TPStreamsSDK.allowFallbackToL3, + ) debugLog("Player INIT - Instance created for assetId: $assetId") synchronized(TPStreamsPlayer::class.java) { activePlayerCount++ @@ -407,9 +436,9 @@ private constructor( } override fun onPlayerError(error: PlaybackException) { - if (isDrmLicenseExpiredError(error)) { - Log.d("TPStreamsPlayer", "DRM error detected for asset: $assetId") - DownloadController.renewDrmLicense(context, assetId, this@TPStreamsPlayer) + if (error.errorCode == PlaybackException.ERROR_CODE_DRM_LICENSE_EXPIRED && isDownloadedAsset(assetId)) { + Log.d("TPStreamsPlayer", "DRM license expired for downloaded asset: $assetId") + DownloadController.renewDrmLicense(context, assetId, this@TPStreamsPlayer) return } @@ -420,16 +449,60 @@ private constructor( return } + // --- DRM fallback to L3 --- + // Covers all DRM errors worth retrying at L3: + // PROVISIONING_FAILED, LICENSE_ACQUISITION_FAILED, SYSTEM_ERROR, + // DISALLOWED_OPERATION, MediaCodec.CryptoException + // DRM_LICENSE_EXPIRED is excluded — it has its own renewal path above. + if (WidevinePlaybackLevelResolver.isFallbackAllowed() && + isDrmContent && + WidevinePlaybackLevelResolver.isDrmFallbackError(error) && + !l3FallbackAttempted + ) { + if (!WidevinePlaybackLevelResolver.isAlreadyOnL3PlaybackLevel()) { + if (WidevinePlaybackLevelResolver.isDrmPermanentFailure(error)) { + // e.g. PROVISIONING_FAILED: device certificate rejected by Google. + // Persist L3 across all future sessions. + Log.w("TPStreamsPlayer", "Permanent DRM failure — persisting L3 for all sessions: $assetId") + WidevinePlaybackLevelResolver.persistForceL3(context) + } else { + // Transient failure (license acquisition, system error, hardware fault). + // Fall back to L3 for this session only — device may recover next launch. + Log.w("TPStreamsPlayer", "Transient DRM failure — session L3 fallback for asset: $assetId") + WidevinePlaybackLevelResolver.forceL3ForSession() + } + if (retryPlaybackAfterL3Persist()) { + Sentry.addBreadcrumb(Breadcrumb().apply { + setMessage("L3 fallback triggered after DRM failure") + setData("asset_id", assetId) + setData("error_code", error.errorCodeName) + setData("is_permanent", WidevinePlaybackLevelResolver.isDrmPermanentFailure(error).toString()) + setData("native_level", drmSecurityLevel) + }) + return + } + } + } + if (isNetworkError(error)) { networkDiagnosticsManager.handleError(error.toError(), error, cdnHostname, decoderState, mediaUrl) return } - + // Non-network errors go directly to _listener?.onError() (not onNetworkError). // Network errors route through handleError → manager → _listener?.onNetworkError(). debugLog("Player ERROR - ${error.errorCodeName}") val errorPlayerId = SentryLogger.generatePlayerIdString() - SentryLogger.logPlaybackException(error, assetId, errorPlayerId, drmLicenseUrl = drmLicenseUrl, context = context, player = exoPlayer, decoderState = decoderState) + SentryLogger.logPlaybackException( + error, + assetId, + errorPlayerId, + drmLicenseUrl = drmLicenseUrl, + context = context, + player = exoPlayer, + decoderState = decoderState, + drmSecurityLevel = drmSecurityLevel + ) val errorType = error.toError() val errorMessage = error.getErrorMessage(errorPlayerId) @@ -469,12 +542,54 @@ private constructor( } } - private fun isDrmLicenseExpiredError(error: PlaybackException): Boolean { - val cause = error.cause - return error.errorCode == PlaybackException.ERROR_CODE_DRM_LICENSE_EXPIRED || - error.errorCode == PlaybackException.ERROR_CODE_DRM_DISALLOWED_OPERATION || - error.errorCode == PlaybackException.ERROR_CODE_DRM_SYSTEM_ERROR || - cause is MediaCodec.CryptoException + private fun retryPlaybackAfterL3Persist(): Boolean { + if (l3FallbackAttempted) { + Log.w("TPStreamsPlayer", "L3 fallback retry skipped: already attempted for asset $assetId") + return false + } + + val mediaItem = exoPlayer.currentMediaItem ?: return false + val retryMediaItem = buildMediaItemForL3Retry(mediaItem) ?: run { + Log.w("TPStreamsPlayer", "L3 fallback retry aborted: could not preserve DRM configuration for asset $assetId") + return false + } + + l3FallbackAttempted = true + val currentPosition = exoPlayer.currentPosition + val playWhenReady = exoPlayer.playWhenReady + + playerScope.launch(Dispatchers.Main) { + exoPlayer.stop() + exoPlayer.clearMediaItems() + val startPositionMs = if (!_isLiveStream && currentPosition > 0) { + currentPosition + } else { + C.TIME_UNSET + } + exoPlayer.setMediaItem(retryMediaItem, startPositionMs) + exoPlayer.prepare() + exoPlayer.playWhenReady = playWhenReady + } + return true + } + + private fun buildMediaItemForL3Retry(mediaItem: MediaItem): MediaItem? { + val localConfiguration = mediaItem.localConfiguration ?: return mediaItem + val drmConfiguration = localConfiguration.drmConfiguration + if (isDrmContent && drmConfiguration == null) { + return null + } + val builder = mediaItem.buildUpon() + drmConfiguration?.let { builder.setDrmConfiguration(it) } + return builder.build() + } + + private fun isDownloadedAsset(assetId: String): Boolean { + return try { + DownloadClient.getInstance(context).getDownload(assetId) != null + } catch (_: Exception) { + false + } } @OptIn(androidx.media3.common.util.UnstableApi::class) @@ -558,6 +673,7 @@ private constructor( val audioAttributes = buildAudioAttributes(assetInfo.enableDrm) exoPlayer.setAudioAttributes(audioAttributes, true) + l3FallbackAttempted = false debugLog("MediaItem SET - ${result.mediaItem.mediaId}") exoPlayer.setMediaItem(result.mediaItem) debugLog("Player PREPARE") @@ -601,6 +717,7 @@ private constructor( val audioAttributes = buildAudioAttributes(isDrm) exoPlayer.setAudioAttributes(audioAttributes, true) + l3FallbackAttempted = false exoPlayer.setMediaItem(downloadedMediaItem) exoPlayer.prepare() isPrepared = true @@ -643,6 +760,7 @@ private constructor( exoPlayer.clearMediaItems() debugLog("MediaItem SET (Download) - ${mediaItem.mediaId}") + l3FallbackAttempted = false exoPlayer.setMediaItem(mediaItem) debugLog("Player PREPARE") exoPlayer.prepare() @@ -1023,9 +1141,14 @@ private constructor( .setCache(DownloadController.downloadCache) .setUpstreamDataSourceFactory(DownloadController.httpDataSourceFactory) .setCacheWriteDataSinkFactory(null) - + + val drmSessionManagerProvider = WidevineDrmSessionManagerProvider( + DownloadController.httpDataSourceFactory, + ) + val mediaSourceFactory = DefaultMediaSourceFactory(context) .setDataSourceFactory(cacheDataSourceFactory) + .setDrmSessionManagerProvider(drmSessionManagerProvider) return ExoPlayer.Builder(context, renderersFactory) .setMediaSourceFactory(mediaSourceFactory) diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/TPStreamsSDK.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/TPStreamsSDK.kt index 1d192890..b922e324 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/TPStreamsSDK.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/TPStreamsSDK.kt @@ -22,9 +22,17 @@ object TPStreamsSDK { _orgCode = value } + internal var allowFallbackToL3: Boolean = false + private set + @JvmStatic @JvmOverloads - fun init(orgId: String, provider: Provider = Provider.TPStreams, authToken: String? = null) { + fun init( + orgId: String, + provider: Provider = Provider.TPStreams, + authToken: String? = null, + allowFallbackToL3: Boolean = false, + ) { require(orgId.isNotBlank()) { "orgId cannot be empty." } if (provider == Provider.TPStreams && authToken != null) { throw IllegalArgumentException("authToken must be null for TPStreams provider") @@ -36,6 +44,7 @@ object TPStreamsSDK { } _orgCode = orgId this.authToken = authToken + this.allowFallbackToL3 = allowFallbackToL3 } internal fun getAuthHeaders(): Map { diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/TestpressSDK.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/TestpressSDK.kt index acbec37b..4ca37b15 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/TestpressSDK.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/TestpressSDK.kt @@ -8,7 +8,11 @@ object TestpressSDK { */ @JvmStatic @JvmOverloads - fun init(subdomain: String, authToken: String? = null) { - TPStreamsSDK.init(subdomain, TPStreamsSDK.Provider.TestPress, authToken) + fun init( + subdomain: String, + authToken: String? = null, + allowFallbackToL3: Boolean = false, + ) { + TPStreamsSDK.init(subdomain, TPStreamsSDK.Provider.TestPress, authToken, allowFallbackToL3) } } diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/download/DownloadController.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/download/DownloadController.kt index 5404f973..327f1bbc 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/download/DownloadController.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/download/DownloadController.kt @@ -27,6 +27,8 @@ import androidx.core.app.NotificationCompat import androidx.media3.exoplayer.drm.OfflineLicenseHelper import androidx.media3.exoplayer.drm.DrmSessionEventListener import com.tpstreams.player.R +import com.tpstreams.player.util.WidevinePlaybackLevelResolver +import com.tpstreams.player.util.WidevineDrmSessionManagerProvider import androidx.media3.common.TrackSelectionParameters import androidx.media3.common.Format import androidx.media3.common.C @@ -73,6 +75,7 @@ object DownloadController { if (isInitialized) return val appContext = context.applicationContext + WidevinePlaybackLevelResolver.initialize(appContext, TPStreamsSDK.allowFallbackToL3) createNotificationChannel(appContext) @@ -563,9 +566,8 @@ object DownloadController { try { val drmFormat = findDrmFormat(helper) if (drmFormat != null) { - val licenseHelper = OfflineLicenseHelper.newWidevineInstance( + val licenseHelper = WidevineDrmSessionManagerProvider.createOfflineLicenseHelper( licenseUri, - false, getDataSourceFactory(context), DrmSessionEventListener.EventDispatcher() ) @@ -694,9 +696,8 @@ object DownloadController { baseRequest: DownloadRequest, dataSourceFactory: DataSource.Factory ): DownloadRequest? { - val licenseHelper = OfflineLicenseHelper.newWidevineInstance( + val licenseHelper = WidevineDrmSessionManagerProvider.createOfflineLicenseHelper( licenseUri, - false, dataSourceFactory, DrmSessionEventListener.EventDispatcher() ) diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/DecoderInfoProvider.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/DecoderInfoProvider.kt index 346182c1..95821f95 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/DecoderInfoProvider.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/DecoderInfoProvider.kt @@ -2,10 +2,8 @@ package com.tpstreams.player.util import android.media.MediaCodecInfo import android.media.MediaCodecList -import android.media.MediaDrm import android.os.Build import com.tpstreams.player.data.PlayerDecoderState -import java.util.UUID import java.util.concurrent.ConcurrentHashMap /** @@ -21,11 +19,9 @@ import java.util.concurrent.ConcurrentHashMap */ internal object DecoderInfoProvider { - private const val WIDEVINE_UUID_STRING = "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" - private val WIDEVINE_UUID = UUID.fromString(WIDEVINE_UUID_STRING) - - /** Cached Widevine security level. Collected once via MediaDrm. */ - private val widevineLevel: String? by lazy { collectWidevineLevel() } + /** Native Widevine security level sourced from [WidevinePlaybackLevelResolver]. */ + private val widevineLevel: String? + get() = WidevinePlaybackLevelResolver.getNativeWidevineLevel() /** Cache of isDecoderHardware results to avoid redundant MediaCodecList enumeration. */ private val decoderHardwareCache = ConcurrentHashMap() @@ -34,6 +30,9 @@ internal object DecoderInfoProvider { fun buildTags(decoderState: PlayerDecoderState?): Map { return buildMap { widevineLevel?.let { put("widevine_security_level", it) } + WidevinePlaybackLevelResolver.getPlaybackLevelOrNull()?.let { + put("widevine_playback_level", it.name) + } val activeCount = CodecManager.getActiveDecoderCount() put("active_decoder_count", activeCount.toString()) decoderState?.videoDecoderName?.let { put("video_decoder_name", it) } @@ -54,6 +53,9 @@ internal object DecoderInfoProvider { val activeCount = CodecManager.getActiveDecoderCount() put("active_decoder_count", activeCount) widevineLevel?.let { put("widevine_security_level", it) } + WidevinePlaybackLevelResolver.getPlaybackLevelOrNull()?.let { + put("widevine_playback_level", it.name) + } } } @@ -101,18 +103,4 @@ internal object DecoderInfoProvider { if (lower.contains("omx.google.") || lower.contains(".sw.") || lower.contains("avc.decoder")) return false return true } - - private fun collectWidevineLevel(): String? { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) return null - return try { - val mediaDrm = MediaDrm(WIDEVINE_UUID) - try { - mediaDrm.getPropertyString("securityLevel") - } finally { - if (Build.VERSION.SDK_INT >= 28) mediaDrm.close() else mediaDrm.release() - } - } catch (_: Exception) { - null - } - } } diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkRecoveryHandler.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkRecoveryHandler.kt index 9456ecd9..198d646d 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkRecoveryHandler.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkRecoveryHandler.kt @@ -99,6 +99,7 @@ class NetworkRecoveryHandler(context: Context) { fun isNetworkError(error: PlaybackException): Boolean { if (error.errorCode == PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS) return false if (error.isAuthOrContentHttpFailure()) return false + if (error.errorCode in 6000..6999) return false return error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED || error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT || (error.cause is java.io.IOException && error.errorCode != PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND) diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/SentryLogger.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/SentryLogger.kt index a90f62f2..73911f5b 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/SentryLogger.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/SentryLogger.kt @@ -97,7 +97,8 @@ internal object SentryLogger { rootCause: String? = null, context: Context? = null, player: Player? = null, - decoderState: PlayerDecoderState? = null + decoderState: PlayerDecoderState? = null, + drmSecurityLevel: String = "unknown" ): String? { return Sentry.captureException(error) { scope -> val nowEpochMs = System.currentTimeMillis() @@ -110,7 +111,13 @@ internal object SentryLogger { scope.setTag("playerId", playerId) assetId?.let { scope.setTag("assetId", it) } drmLicenseUrl?.takeIf { it.isNotEmpty() }?.let { scope.setTag("drmLicenseUrl", it) } - rootCause?.let { scope.setTag("rootCause", it) } + scope.setTag("widevine_security_level", drmSecurityLevel) + // Set rootCause: prefer explicit value, then auto-derive from DRM error code + val derivedRootCause = rootCause ?: when { + error.errorCodeName?.contains("DRM", ignoreCase = true) == true -> error.errorCodeName + else -> null + } + derivedRootCause?.let { scope.setTag("rootCause", it) } scope.setContexts( "TPStreamsPlayer", mapOf( diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevineDrmSessionManagerProvider.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevineDrmSessionManagerProvider.kt new file mode 100644 index 00000000..5e503e9b --- /dev/null +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevineDrmSessionManagerProvider.kt @@ -0,0 +1,120 @@ +package com.tpstreams.player.util + +import androidx.annotation.GuardedBy +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.drm.DefaultDrmSessionManager +import androidx.media3.exoplayer.drm.DefaultDrmSessionManagerProvider +import androidx.media3.exoplayer.drm.DrmSessionManager +import androidx.media3.exoplayer.drm.DrmSessionManagerProvider +import androidx.media3.exoplayer.drm.DummyExoMediaDrm +import androidx.media3.exoplayer.drm.ExoMediaDrm +import androidx.media3.exoplayer.drm.FrameworkMediaDrm +import androidx.media3.exoplayer.drm.HttpMediaDrmCallback +import android.util.Log +import com.google.common.primitives.Ints +import java.util.Objects + +/** + * Supplies [DrmSessionManager] instances using the cached playback level from + * [WidevinePlaybackLevelResolver]. + */ +@UnstableApi +internal class WidevineDrmSessionManagerProvider( + private val drmHttpDataSourceFactory: DataSource.Factory, +) : DrmSessionManagerProvider { + + private val lock = Any() + + private val defaultProvider = DefaultDrmSessionManagerProvider().apply { + setDrmHttpDataSourceFactory(drmHttpDataSourceFactory) + } + + @GuardedBy("lock") + private var l3DrmConfiguration: MediaItem.DrmConfiguration? = null + + @GuardedBy("lock") + private var l3Manager: DrmSessionManager? = null + + override fun get(mediaItem: MediaItem): DrmSessionManager { + if (!WidevinePlaybackLevelResolver.shouldUseL3Drm()) { + return defaultProvider.get(mediaItem) + } + + val drmConfiguration = mediaItem.localConfiguration?.drmConfiguration + ?: return DrmSessionManager.DRM_UNSUPPORTED + + synchronized(lock) { + if (!Objects.equals(drmConfiguration, l3DrmConfiguration)) { + l3DrmConfiguration = drmConfiguration + l3Manager = createL3Manager(drmConfiguration) + } + return checkNotNull(l3Manager) + } + } + + private fun createL3Manager(drmConfiguration: MediaItem.DrmConfiguration): DrmSessionManager { + val httpDrmCallback = HttpMediaDrmCallback( + drmConfiguration.licenseUri?.toString(), + drmConfiguration.forceDefaultLicenseUri, + drmHttpDataSourceFactory, + ) + for ((key, value) in drmConfiguration.licenseRequestHeaders) { + httpDrmCallback.setKeyRequestProperty(key, value) + } + + val drmSessionManager = DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(drmConfiguration.scheme, L3_EXO_MEDIA_DRM_PROVIDER) + .setMultiSession(drmConfiguration.multiSession) + .setPlayClearSamplesWithoutKeys(drmConfiguration.playClearContentWithoutKey) + .setUseDrmSessionsForClearContent( + *Ints.toArray(drmConfiguration.forcedSessionTrackTypes), + ) + .build(httpDrmCallback) + drmSessionManager.setMode(DefaultDrmSessionManager.MODE_PLAYBACK, drmConfiguration.keySetId) + return drmSessionManager + } + + companion object { + private const val TAG = "WidevineDrmSessionManager" + + internal val L3_EXO_MEDIA_DRM_PROVIDER = ExoMediaDrm.Provider { uuid -> + try { + FrameworkMediaDrm.newInstance(uuid).apply { + if (uuid == C.WIDEVINE_UUID) { + setPropertyString("securityLevel", "L3") + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to create L3 MediaDrm for uuid=$uuid; playback will fail at DRM init", e) + DummyExoMediaDrm() + } + } + + fun createOfflineLicenseHelper( + licenseUri: String, + dataSourceFactory: DataSource.Factory, + eventDispatcher: androidx.media3.exoplayer.drm.DrmSessionEventListener.EventDispatcher = androidx.media3.exoplayer.drm.DrmSessionEventListener.EventDispatcher() + ): androidx.media3.exoplayer.drm.OfflineLicenseHelper { + if (!WidevinePlaybackLevelResolver.shouldUseL3Drm()) { + return androidx.media3.exoplayer.drm.OfflineLicenseHelper.newWidevineInstance( + licenseUri, + false, + dataSourceFactory, + eventDispatcher + ) + } + val httpDrmCallback = HttpMediaDrmCallback( + licenseUri, + false, + dataSourceFactory + ) + val drmSessionManager = DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(C.WIDEVINE_UUID, L3_EXO_MEDIA_DRM_PROVIDER) + .build(httpDrmCallback) + return androidx.media3.exoplayer.drm.OfflineLicenseHelper(drmSessionManager, eventDispatcher) + } + } +} diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevinePlaybackLevel.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevinePlaybackLevel.kt new file mode 100644 index 00000000..6d36c56b --- /dev/null +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevinePlaybackLevel.kt @@ -0,0 +1,13 @@ +package com.tpstreams.player.util + +/** + * Widevine security level used for DRM playback after init-time resolution. + * + * [L1] uses the device default Widevine CDM (hardware when available). + * [L3] forces software Widevine decryption for the lifetime of the app install + * (or until the persisted preference is cleared). + */ +internal enum class WidevinePlaybackLevel { + L1, + L3, +} diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevinePlaybackLevelResolver.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevinePlaybackLevelResolver.kt new file mode 100644 index 00000000..abe0281d --- /dev/null +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevinePlaybackLevelResolver.kt @@ -0,0 +1,264 @@ +package com.tpstreams.player.util + +import android.content.Context +import android.content.SharedPreferences +import android.media.DeniedByServerException +import android.media.MediaDrm +import android.os.Build +import android.util.Log +import androidx.media3.common.PlaybackException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.UUID +import java.util.concurrent.TimeUnit + +/** + * Resolves and caches which Widevine level to use for all DRM playback. + * + * Resolution is non-blocking: + * 1. Return persisted L3 if a previous session marked this device as permanently L3-only. + * 2. Use native L3 when the device reports L3. + * 3. For L1-capable devices, immediately default to native L1 without blocking player creation, + * and kick off an asynchronous provisioning probe on a background dispatcher. + * 4. Only persist L3 when the provisioning server explicitly rejects the device certificate + * (e.g., HTTP 4xx / DeniedByServerException); transient network/timeout/5xx errors do NOT persist L3. + */ +internal object WidevinePlaybackLevelResolver { + + private const val TAG = "WidevinePlaybackLevel" + private const val PREFS_NAME = "tpstreams_drm_playback" + private const val KEY_FORCE_L3 = "force_l3" + + private val WIDEVINE_UUID: UUID = UUID.fromString("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed") + + private val provisioningClient = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .build() + + private val probeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Volatile + private var appContext: Context? = null + + @Volatile + private var cachedLevel: WidevinePlaybackLevel? = null + + @Volatile + private var nativeLevel: String? = null + + @Volatile + private var allowFallbackToL3: Boolean = false + + private val initLock = Any() + + enum class ProvisioningProbeResult { + SUCCESS, + PERMANENT_FAILURE, + TRANSIENT_FAILURE + } + + fun initialize(context: Context, allowFallbackToL3: Boolean = false) { + synchronized(initLock) { + this.allowFallbackToL3 = this.allowFallbackToL3 || allowFallbackToL3 + if (cachedLevel != null) return + + val applicationContext = context.applicationContext + appContext = applicationContext + val prefs = applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + nativeLevel = readNativeWidevineLevel() + val forceL3Persisted = prefs.getBoolean(KEY_FORCE_L3, false) + + if (nativeLevel == "L3" || (this.allowFallbackToL3 && forceL3Persisted)) { + cachedLevel = WidevinePlaybackLevel.L3 + Log.i( + TAG, + "Resolved playback level=L3, native=$nativeLevel, persistedL3=$forceL3Persisted, fallbackAllowed=${this.allowFallbackToL3}", + ) + } else { + cachedLevel = WidevinePlaybackLevel.L1 + Log.i( + TAG, + "Defaulting playback level to L1 (native=$nativeLevel, fallbackAllowed=${this.allowFallbackToL3})", + ) + if (this.allowFallbackToL3) { + probeScope.launch { + when (runProvisioningProbe()) { + ProvisioningProbeResult.PERMANENT_FAILURE -> { + Log.w(TAG, "Provisioning probe permanently rejected device; persisting L3") + synchronized(initLock) { + persistForceL3ToPrefs(prefs) + } + } + ProvisioningProbeResult.SUCCESS -> { + Log.i(TAG, "Provisioning probe succeeded; maintaining L1") + } + ProvisioningProbeResult.TRANSIENT_FAILURE -> { + Log.i( + TAG, + "Provisioning probe transient failure; maintaining L1 for this session without persisting L3", + ) + } + } + } + } + } + } + } + + fun isFallbackAllowed(): Boolean = allowFallbackToL3 + + fun shouldUseL3Drm(): Boolean { + if (nativeLevel == "L3") return true + if (!allowFallbackToL3) return false + return cachedLevel == WidevinePlaybackLevel.L3 + } + + fun getPlaybackLevel(): WidevinePlaybackLevel { + return cachedLevel ?: WidevinePlaybackLevel.L1 + } + + fun getPlaybackLevelOrNull(): WidevinePlaybackLevel? = cachedLevel + + fun getNativeWidevineLevel(): String? = nativeLevel + + fun isAlreadyOnL3PlaybackLevel(): Boolean = getPlaybackLevelOrNull() == WidevinePlaybackLevel.L3 + + fun forceL3ForSession() { + if (!allowFallbackToL3) return + synchronized(initLock) { + cachedLevel = WidevinePlaybackLevel.L3 + } + Log.i(TAG, "L3 forced for current session only (not persisted)") + } + + fun persistForceL3(context: Context? = appContext) { + if (!allowFallbackToL3) return + val prefs = (context ?: appContext)?.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + ?: return + synchronized(initLock) { + persistForceL3ToPrefs(prefs) + } + } + + fun isDrmPermanentFailure(error: PlaybackException): Boolean { + return error.errorCode == PlaybackException.ERROR_CODE_DRM_PROVISIONING_FAILED || + error.errorCode == PlaybackException.ERROR_CODE_DRM_DEVICE_REVOKED + } + + fun isDrmFallbackError(error: PlaybackException): Boolean { + if (when (error.errorCode) { + PlaybackException.ERROR_CODE_DRM_PROVISIONING_FAILED, + PlaybackException.ERROR_CODE_DRM_DEVICE_REVOKED, + PlaybackException.ERROR_CODE_DRM_LICENSE_ACQUISITION_FAILED, + PlaybackException.ERROR_CODE_DRM_SYSTEM_ERROR, + PlaybackException.ERROR_CODE_DRM_DISALLOWED_OPERATION -> true + else -> false + } + ) { + return true + } + var cause: Throwable? = error.cause + while (cause != null) { + if (cause is android.media.MediaCodec.CryptoException) { + return true + } + cause = cause.cause + } + return false + } + + private fun persistForceL3ToPrefs(prefs: SharedPreferences) { + if (prefs.getBoolean(KEY_FORCE_L3, false)) { + cachedLevel = WidevinePlaybackLevel.L3 + Log.i(TAG, "L3 playback level already persisted; skipping duplicate write") + return + } + prefs.edit().putBoolean(KEY_FORCE_L3, true).apply() + cachedLevel = WidevinePlaybackLevel.L3 + Log.i(TAG, "Persisted L3 playback level for future sessions") + } + + private fun readNativeWidevineLevel(): String? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) return null + var mediaDrm: MediaDrm? = null + return try { + mediaDrm = MediaDrm(WIDEVINE_UUID) + mediaDrm.getPropertyString("securityLevel") + } catch (e: Exception) { + Log.w(TAG, "Failed to read native Widevine level: ${e.message}") + null + } finally { + releaseMediaDrm(mediaDrm) + } + } + + private fun runProvisioningProbe(): ProvisioningProbeResult { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) { + return ProvisioningProbeResult.TRANSIENT_FAILURE + } + + var mediaDrm: MediaDrm? = null + return try { + mediaDrm = MediaDrm(WIDEVINE_UUID) + val request = mediaDrm.getProvisionRequest() + val defaultUrl = request.defaultUrl + if (defaultUrl.isNullOrBlank()) { + Log.w(TAG, "Widevine provisioning probe failed: empty defaultUrl") + return ProvisioningProbeResult.TRANSIENT_FAILURE + } + + val httpRequest = Request.Builder() + .url(defaultUrl) + .post(request.data.toRequestBody("application/octet-stream".toMediaType())) + .build() + + provisioningClient.newCall(httpRequest).execute().use { response -> + if (!response.isSuccessful) { + val code = response.code + Log.w(TAG, "Widevine provisioning probe failed: HTTP $code") + return if (code in 400..499) { + ProvisioningProbeResult.PERMANENT_FAILURE + } else { + ProvisioningProbeResult.TRANSIENT_FAILURE + } + } + + val responseBody = response.body?.bytes() + if (responseBody == null) { + Log.w(TAG, "Widevine provisioning probe failed: empty response body") + return ProvisioningProbeResult.TRANSIENT_FAILURE + } + + mediaDrm.provideProvisionResponse(responseBody) + Log.i(TAG, "Widevine provisioning probe succeeded") + ProvisioningProbeResult.SUCCESS + } + } catch (e: DeniedByServerException) { + Log.w(TAG, "Widevine provisioning probe denied by server: ${e.message}") + ProvisioningProbeResult.PERMANENT_FAILURE + } catch (e: Exception) { + Log.w(TAG, "Widevine provisioning probe failed: ${e.message}") + ProvisioningProbeResult.TRANSIENT_FAILURE + } finally { + releaseMediaDrm(mediaDrm) + } + } + + private fun releaseMediaDrm(mediaDrm: MediaDrm?) { + mediaDrm ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + mediaDrm.close() + } else { + @Suppress("DEPRECATION") + mediaDrm.release() + } + } +}