From cd4fffa00c4d8d0429850b27127474bba86a8fc6 Mon Sep 17 00:00:00 2001 From: Balamurugan-100 Date: Sun, 16 Aug 2026 00:30:18 +0530 Subject: [PATCH 01/11] fix: Claude comments --- .../java/com/tpstreams/player/util/NetworkRecoveryHandler.kt | 1 - 1 file changed, 1 deletion(-) 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..034eff07 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 @@ -97,7 +97,6 @@ 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 return error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED || error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT || From 5b1e70584181eae27fbb52506e681121939b8ea8 Mon Sep 17 00:00:00 2001 From: Balamurugan-100 Date: Sun, 16 Aug 2026 10:44:39 +0530 Subject: [PATCH 02/11] fix --- .../java/com/tpstreams/player/util/NetworkRecoveryHandler.kt | 1 + 1 file changed, 1 insertion(+) 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 034eff07..9456ecd9 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 @@ -97,6 +97,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 return error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED || error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT || From 85cf6d0484d3e0ac7be1551c4f37dd2742919003 Mon Sep 17 00:00:00 2001 From: Balamurugan-100 Date: Sat, 15 Aug 2026 23:19:10 +0530 Subject: [PATCH 03/11] feat: Add L3 fallback for DRM provisioning failure --- .../com/tpstreams/player/TPStreamsPlayer.kt | 105 +++++++- .../player/download/DownloadController.kt | 2 + .../player/util/DecoderInfoProvider.kt | 6 + .../com/tpstreams/player/util/SentryLogger.kt | 11 +- .../util/WidevineDrmSessionManagerProvider.kt | 93 +++++++ .../player/util/WidevinePlaybackLevel.kt | 13 + .../util/WidevinePlaybackLevelResolver.kt | 248 ++++++++++++++++++ 7 files changed, 465 insertions(+), 13 deletions(-) create mode 100644 tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevineDrmSessionManagerProvider.kt create mode 100644 tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevinePlaybackLevel.kt create mode 100644 tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevinePlaybackLevelResolver.kt 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..4d16a37d 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 @@ -55,6 +55,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 +108,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. * @@ -407,9 +418,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) { + Log.d("TPStreamsPlayer", "DRM license expired for asset: $assetId") + DownloadController.renewDrmLicense(context, assetId, this@TPStreamsPlayer) return } @@ -424,12 +435,56 @@ private constructor( networkDiagnosticsManager.handleError(error.toError(), error, cdnHostname, decoderState, mediaUrl) 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 (isDrmContent && WidevinePlaybackLevelResolver.isDrmFallbackError(error)) { + if (!WidevinePlaybackLevelResolver.shouldForceL3()) { + 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 (isDrmLicenseExpiredError(error)) { + Log.d("TPStreamsPlayer", "DRM license renewal triggered for asset: $assetId") + DownloadController.renewDrmLicense(context, assetId, this@TPStreamsPlayer) + 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 +524,35 @@ private constructor( } } + private fun retryPlaybackAfterL3Persist(): Boolean { + val mediaItem = exoPlayer.currentMediaItem ?: return false + val currentPosition = exoPlayer.currentPosition + val playWhenReady = exoPlayer.playWhenReady + + playerScope.launch(Dispatchers.Main) { + exoPlayer.stop() + exoPlayer.setMediaItem(mediaItem) + exoPlayer.prepare() + exoPlayer.seekTo(currentPosition) + exoPlayer.playWhenReady = playWhenReady + } + return true + } + 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 + if (error.errorCode == PlaybackException.ERROR_CODE_DRM_LICENSE_EXPIRED) { + return true + } + if (WidevinePlaybackLevelResolver.shouldForceL3()) { + var cause: Throwable? = error.cause + while (cause != null) { + if (cause is MediaCodec.CryptoException) { + return true + } + cause = cause.cause + } + } + return false } @OptIn(androidx.media3.common.util.UnstableApi::class) @@ -1023,9 +1101,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/download/DownloadController.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/download/DownloadController.kt index 5404f973..db82ddbc 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,7 @@ 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 androidx.media3.common.TrackSelectionParameters import androidx.media3.common.Format import androidx.media3.common.C @@ -73,6 +74,7 @@ object DownloadController { if (isInitialized) return val appContext = context.applicationContext + WidevinePlaybackLevelResolver.initialize(appContext) createNotificationChannel(appContext) 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..7ee762bf 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 @@ -34,6 +34,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 +57,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) + } } } 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..2950079b --- /dev/null +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevineDrmSessionManagerProvider.kt @@ -0,0 +1,93 @@ +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 androidx.media3.exoplayer.drm.UnsupportedDrmException +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.shouldForceL3()) { + 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 val L3_EXO_MEDIA_DRM_PROVIDER = ExoMediaDrm.Provider { uuid -> + try { + FrameworkMediaDrm.newInstance(uuid).apply { + if (uuid == C.WIDEVINE_UUID) { + setPropertyString("securityLevel", "L3") + } + } + } catch (_: UnsupportedDrmException) { + DummyExoMediaDrm() + } + } + } +} 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..0019f283 --- /dev/null +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevinePlaybackLevelResolver.kt @@ -0,0 +1,248 @@ +package com.tpstreams.player.util + +import android.content.Context +import android.content.SharedPreferences +import android.media.MediaDrm +import android.os.Build +import android.util.Log +import androidx.media3.common.PlaybackException +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 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking + +/** + * Resolves and caches which Widevine level to use for all DRM playback. + * + * Resolution runs once during SDK/player initialization: + * 1. Return persisted L3 if a previous session marked this device as L3-only. + * 2. Use native L3 when the device reports L3. + * 3. For L1-capable devices, run a Widevine provisioning probe (Google default URL). + * Provisioning HTTP failures (e.g. 500) persist L3 so L1 is never retried. + */ +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() + + @Volatile + private var appContext: Context? = null + + @Volatile + private var cachedLevel: WidevinePlaybackLevel? = null + + @Volatile + private var nativeLevel: String? = null + + fun initialize(context: Context) { + if (cachedLevel != null) return + + synchronized(this) { + 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) + cachedLevel = if (forceL3Persisted || nativeLevel == "L3") { + resolvePlaybackLevel( + forceL3Persisted = forceL3Persisted, + nativeWidevineLevel = nativeLevel, + provisioningSucceeded = { true }, + onPersistForceL3 = { persistForceL3(prefs) }, + ) + } else { + runBlocking(Dispatchers.IO) { + resolvePlaybackLevel( + forceL3Persisted = false, + nativeWidevineLevel = nativeLevel, + provisioningSucceeded = { runProvisioningProbe() }, + onPersistForceL3 = { persistForceL3(prefs) }, + ) + } + } + Log.i( + TAG, + "Resolved playback level=${cachedLevel!!.name}, native=$nativeLevel, persistedL3=${prefs.getBoolean(KEY_FORCE_L3, false)}", + ) + } + } + + fun getPlaybackLevel(): WidevinePlaybackLevel { + return cachedLevel ?: throw IllegalStateException( + "WidevinePlaybackLevelResolver.initialize(context) must be called before playback.", + ) + } + + fun getPlaybackLevelOrNull(): WidevinePlaybackLevel? = cachedLevel + + fun getNativeWidevineLevel(): String? = nativeLevel + + fun shouldForceL3(): Boolean = getPlaybackLevelOrNull() == WidevinePlaybackLevel.L3 + + /** + * Forces L3 for the current in-memory session only. + * Does NOT write to SharedPrefs — the next app launch re-runs the provisioning probe. + * Use for transient DRM failures (license acquisition errors, system errors) where + * the device may recover on the next launch. + * For permanent failures (revoked keybox/certificate), use [persistForceL3] instead. + */ + fun forceL3ForSession() { + cachedLevel = WidevinePlaybackLevel.L3 + Log.i(TAG, "L3 forced for current session only (not persisted)") + } + + fun persistForceL3(context: Context? = appContext) { + val prefs = (context ?: appContext)?.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + ?: return + persistForceL3(prefs) + cachedLevel = WidevinePlaybackLevel.L3 + Log.i(TAG, "Persisted L3 playback level for future sessions") + } + + /** + * Returns true for DRM errors that are permanent device-level failures. + * These warrant persisting L3 across all future sessions (e.g. revoked keybox). + */ + fun isDrmPermanentFailure(error: PlaybackException): Boolean { + return error.errorCode == PlaybackException.ERROR_CODE_DRM_PROVISIONING_FAILED + } + + /** + * Returns true for any DRM error that should trigger an L3 fallback retry. + * Covers both permanent failures (provisioning) and transient ones + * (license acquisition, system errors, disallowed operations, hardware crypto failures). + * Excludes [PlaybackException.ERROR_CODE_DRM_LICENSE_EXPIRED] which has its own renewal path. + */ + fun isDrmFallbackError(error: PlaybackException): Boolean { + if (when (error.errorCode) { + PlaybackException.ERROR_CODE_DRM_PROVISIONING_FAILED, + 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 + } + + internal fun resolvePlaybackLevel( + forceL3Persisted: Boolean, + nativeWidevineLevel: String?, + provisioningSucceeded: () -> Boolean, + onPersistForceL3: () -> Unit, + ): WidevinePlaybackLevel { + if (forceL3Persisted) { + return WidevinePlaybackLevel.L3 + } + + when (nativeWidevineLevel) { + "L3" -> return WidevinePlaybackLevel.L3 + "L1" -> { + if (provisioningSucceeded()) { + return WidevinePlaybackLevel.L1 + } + onPersistForceL3() + return WidevinePlaybackLevel.L3 + } + else -> { + if (provisioningSucceeded()) { + return WidevinePlaybackLevel.L1 + } + onPersistForceL3() + return WidevinePlaybackLevel.L3 + } + } + } + + private fun persistForceL3(prefs: SharedPreferences) { + prefs.edit().putBoolean(KEY_FORCE_L3, true).apply() + } + + 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 { + mediaDrm?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + it.close() + } else { + @Suppress("DEPRECATION") + it.release() + } + } + } + } + + private fun runProvisioningProbe(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) return false + + var mediaDrm: MediaDrm? = null + return try { + mediaDrm = MediaDrm(WIDEVINE_UUID) + val request = mediaDrm.provisionRequest + val response = provisioningClient.newCall( + Request.Builder() + .url(request.defaultUrl) + .post(request.data.toRequestBody("application/octet-stream".toMediaType())) + .build(), + ).execute() + + if (!response.isSuccessful) { + Log.w(TAG, "Widevine provisioning probe failed: HTTP ${response.code}") + return false + } + + val responseBody = response.body?.bytes() + if (responseBody == null) { + Log.w(TAG, "Widevine provisioning probe failed: empty response body") + return false + } + + mediaDrm.provideProvisionResponse(responseBody) + Log.i(TAG, "Widevine provisioning probe succeeded") + true + } catch (e: Exception) { + Log.w(TAG, "Widevine provisioning probe failed: ${e.message}") + false + } finally { + mediaDrm?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + it.close() + } else { + @Suppress("DEPRECATION") + it.release() + } + } + } + } +} From fec1311aa656fe20ac17e6c472b2c3939a3c80e0 Mon Sep 17 00:00:00 2001 From: Balamurugan-100 Date: Sat, 15 Aug 2026 23:50:17 +0530 Subject: [PATCH 04/11] Fix: Claude comments --- .../com/tpstreams/player/TPStreamsPlayer.kt | 28 ++--- .../util/WidevinePlaybackLevelResolver.kt | 116 ++++++++++-------- 2 files changed, 76 insertions(+), 68 deletions(-) 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 4d16a37d..2e5397f7 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 @@ -418,8 +418,8 @@ private constructor( } override fun onPlayerError(error: PlaybackException) { - if (error.errorCode == PlaybackException.ERROR_CODE_DRM_LICENSE_EXPIRED) { - Log.d("TPStreamsPlayer", "DRM license expired for asset: $assetId") + 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 } @@ -466,11 +466,7 @@ private constructor( } } } - if (isDrmLicenseExpiredError(error)) { - Log.d("TPStreamsPlayer", "DRM license renewal triggered for asset: $assetId") - DownloadController.renewDrmLicense(context, assetId, this@TPStreamsPlayer) - return - } + // Non-network errors go directly to _listener?.onError() (not onNetworkError). // Network errors route through handleError → manager → _listener?.onNetworkError(). debugLog("Player ERROR - ${error.errorCodeName}") @@ -539,20 +535,12 @@ private constructor( return true } - private fun isDrmLicenseExpiredError(error: PlaybackException): Boolean { - if (error.errorCode == PlaybackException.ERROR_CODE_DRM_LICENSE_EXPIRED) { - return true - } - if (WidevinePlaybackLevelResolver.shouldForceL3()) { - var cause: Throwable? = error.cause - while (cause != null) { - if (cause is MediaCodec.CryptoException) { - return true - } - cause = cause.cause - } + private fun isDownloadedAsset(assetId: String): Boolean { + return try { + DownloadClient.getInstance(context).getDownload(assetId) != null + } catch (_: Exception) { + false } - return false } @OptIn(androidx.media3.common.util.UnstableApi::class) 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 index 0019f283..7ade9493 100644 --- 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 @@ -6,23 +6,26 @@ 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.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 -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking /** * Resolves and caches which Widevine level to use for all DRM playback. * - * Resolution runs once during SDK/player initialization: - * 1. Return persisted L3 if a previous session marked this device as L3-only. + * 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, run a Widevine provisioning probe (Google default URL). - * Provisioning HTTP failures (e.g. 500) persist L3 so L1 is never retried. + * 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 { @@ -47,6 +50,12 @@ internal object WidevinePlaybackLevelResolver { @Volatile private var nativeLevel: String? = null + enum class ProvisioningProbeResult { + SUCCESS, + PERMANENT_FAILURE, + TRANSIENT_FAILURE + } + fun initialize(context: Context) { if (cachedLevel != null) return @@ -58,34 +67,41 @@ internal object WidevinePlaybackLevelResolver { val prefs = applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) nativeLevel = readNativeWidevineLevel() val forceL3Persisted = prefs.getBoolean(KEY_FORCE_L3, false) - cachedLevel = if (forceL3Persisted || nativeLevel == "L3") { - resolvePlaybackLevel( - forceL3Persisted = forceL3Persisted, - nativeWidevineLevel = nativeLevel, - provisioningSucceeded = { true }, - onPersistForceL3 = { persistForceL3(prefs) }, + + if (forceL3Persisted || nativeLevel == "L3") { + cachedLevel = WidevinePlaybackLevel.L3 + Log.i( + TAG, + "Resolved playback level=L3, native=$nativeLevel, persistedL3=$forceL3Persisted", ) } else { - runBlocking(Dispatchers.IO) { - resolvePlaybackLevel( - forceL3Persisted = false, - nativeWidevineLevel = nativeLevel, - provisioningSucceeded = { runProvisioningProbe() }, - onPersistForceL3 = { persistForceL3(prefs) }, - ) + // Default immediately to native level (L1) without blocking the calling thread + cachedLevel = WidevinePlaybackLevel.L1 + Log.i( + TAG, + "Defaulting playback level to L1 (native=$nativeLevel); launching background provisioning probe", + ) + // Run provisioning probe asynchronously in background + CoroutineScope(Dispatchers.IO).launch { + when (runProvisioningProbe()) { + ProvisioningProbeResult.PERMANENT_FAILURE -> { + Log.w(TAG, "Provisioning probe permanently rejected device; persisting L3") + persistForceL3(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") + } + } } } - Log.i( - TAG, - "Resolved playback level=${cachedLevel!!.name}, native=$nativeLevel, persistedL3=${prefs.getBoolean(KEY_FORCE_L3, false)}", - ) } } fun getPlaybackLevel(): WidevinePlaybackLevel { - return cachedLevel ?: throw IllegalStateException( - "WidevinePlaybackLevelResolver.initialize(context) must be called before playback.", - ) + return cachedLevel ?: WidevinePlaybackLevel.L1 } fun getPlaybackLevelOrNull(): WidevinePlaybackLevel? = cachedLevel @@ -152,28 +168,21 @@ internal object WidevinePlaybackLevelResolver { internal fun resolvePlaybackLevel( forceL3Persisted: Boolean, nativeWidevineLevel: String?, - provisioningSucceeded: () -> Boolean, + probeResult: ProvisioningProbeResult, onPersistForceL3: () -> Unit, ): WidevinePlaybackLevel { - if (forceL3Persisted) { + if (forceL3Persisted || nativeLevel == "L3") { return WidevinePlaybackLevel.L3 } - when (nativeWidevineLevel) { - "L3" -> return WidevinePlaybackLevel.L3 - "L1" -> { - if (provisioningSucceeded()) { - return WidevinePlaybackLevel.L1 - } + return when (probeResult) { + ProvisioningProbeResult.PERMANENT_FAILURE -> { onPersistForceL3() - return WidevinePlaybackLevel.L3 + WidevinePlaybackLevel.L3 } - else -> { - if (provisioningSucceeded()) { - return WidevinePlaybackLevel.L1 - } - onPersistForceL3() - return WidevinePlaybackLevel.L3 + ProvisioningProbeResult.SUCCESS, + ProvisioningProbeResult.TRANSIENT_FAILURE -> { + WidevinePlaybackLevel.L1 } } } @@ -203,8 +212,8 @@ internal object WidevinePlaybackLevelResolver { } } - private fun runProvisioningProbe(): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) return false + 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 { @@ -218,22 +227,33 @@ internal object WidevinePlaybackLevelResolver { ).execute() if (!response.isSuccessful) { - Log.w(TAG, "Widevine provisioning probe failed: HTTP ${response.code}") - return false + val code = response.code + Log.w(TAG, "Widevine provisioning probe failed: HTTP $code") + // HTTP 4xx (e.g. 400, 403, 404) indicates explicit client/certificate rejection by the server. + // HTTP 5xx indicates transient server issue. + 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 false + return ProvisioningProbeResult.TRANSIENT_FAILURE } mediaDrm.provideProvisionResponse(responseBody) Log.i(TAG, "Widevine provisioning probe succeeded") - true + ProvisioningProbeResult.SUCCESS } catch (e: Exception) { Log.w(TAG, "Widevine provisioning probe failed: ${e.message}") - false + if (e is android.media.DeniedByServerException || e.javaClass.simpleName.contains("DeniedByServer")) { + ProvisioningProbeResult.PERMANENT_FAILURE + } else { + ProvisioningProbeResult.TRANSIENT_FAILURE + } } finally { mediaDrm?.let { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { From 6ecbf42f1135a32da586b8cf62de46957a2a2bcb Mon Sep 17 00:00:00 2001 From: Balamurugan-100 Date: Sun, 16 Aug 2026 00:11:21 +0530 Subject: [PATCH 05/11] refactor --- .../com/tpstreams/player/TPStreamsPlayer.kt | 5 ++-- .../player/download/DownloadController.kt | 7 +++-- .../player/util/DecoderInfoProvider.kt | 24 +++-------------- .../util/WidevineDrmSessionManagerProvider.kt | 26 ++++++++++++++++++- .../util/WidevinePlaybackLevelResolver.kt | 22 ---------------- 5 files changed, 34 insertions(+), 50 deletions(-) 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 2e5397f7..053b6269 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 @@ -529,7 +528,9 @@ private constructor( exoPlayer.stop() exoPlayer.setMediaItem(mediaItem) exoPlayer.prepare() - exoPlayer.seekTo(currentPosition) + if (!_isLiveStream) { + exoPlayer.seekTo(currentPosition) + } exoPlayer.playWhenReady = playWhenReady } return true 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 db82ddbc..3013c16c 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 @@ -28,6 +28,7 @@ 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 @@ -565,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() ) @@ -696,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 7ee762bf..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() @@ -107,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/WidevineDrmSessionManagerProvider.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevineDrmSessionManagerProvider.kt index 2950079b..fc14e0d9 100644 --- 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 @@ -78,7 +78,7 @@ internal class WidevineDrmSessionManagerProvider( } companion object { - private val L3_EXO_MEDIA_DRM_PROVIDER = ExoMediaDrm.Provider { uuid -> + internal val L3_EXO_MEDIA_DRM_PROVIDER = ExoMediaDrm.Provider { uuid -> try { FrameworkMediaDrm.newInstance(uuid).apply { if (uuid == C.WIDEVINE_UUID) { @@ -89,5 +89,29 @@ internal class WidevineDrmSessionManagerProvider( 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.shouldForceL3()) { + 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/WidevinePlaybackLevelResolver.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevinePlaybackLevelResolver.kt index 7ade9493..979bf2a8 100644 --- 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 @@ -165,28 +165,6 @@ internal object WidevinePlaybackLevelResolver { return false } - internal fun resolvePlaybackLevel( - forceL3Persisted: Boolean, - nativeWidevineLevel: String?, - probeResult: ProvisioningProbeResult, - onPersistForceL3: () -> Unit, - ): WidevinePlaybackLevel { - if (forceL3Persisted || nativeLevel == "L3") { - return WidevinePlaybackLevel.L3 - } - - return when (probeResult) { - ProvisioningProbeResult.PERMANENT_FAILURE -> { - onPersistForceL3() - WidevinePlaybackLevel.L3 - } - ProvisioningProbeResult.SUCCESS, - ProvisioningProbeResult.TRANSIENT_FAILURE -> { - WidevinePlaybackLevel.L1 - } - } - } - private fun persistForceL3(prefs: SharedPreferences) { prefs.edit().putBoolean(KEY_FORCE_L3, true).apply() } From 44c2a16899fbf5913bc435e7d413c3b925ef0bf4 Mon Sep 17 00:00:00 2001 From: Balamurugan-100 Date: Sun, 16 Aug 2026 10:08:20 +0530 Subject: [PATCH 06/11] enhancement --- .../com/tpstreams/player/PlayerUIViewModel.kt | 6 +- .../com/tpstreams/player/TPStreamsPlayer.kt | 17 ++- .../com/tpstreams/player/TestpressPlayer.kt | 6 +- .../util/WidevineDrmSessionManagerProvider.kt | 6 +- .../util/WidevinePlaybackLevelResolver.kt | 105 ++---------------- 5 files changed, 32 insertions(+), 108 deletions(-) diff --git a/app/src/main/java/com/tpstreams/player/PlayerUIViewModel.kt b/app/src/main/java/com/tpstreams/player/PlayerUIViewModel.kt index 814db748..bd6a65c6 100644 --- a/app/src/main/java/com/tpstreams/player/PlayerUIViewModel.kt +++ b/app/src/main/java/com/tpstreams/player/PlayerUIViewModel.kt @@ -21,7 +21,8 @@ class PlayerUIViewModel(application: Application) : AndroidViewModel(application assetId = assetId, accessToken = accessToken, shouldAutoPlay = true, - enableDownload = true + enableDownload = true, + allowFallbackToL3 = true ) } else { TPStreamsPlayer.create( @@ -30,7 +31,8 @@ class PlayerUIViewModel(application: Application) : AndroidViewModel(application accessToken = accessToken, shouldAutoPlay = true, enableDownload = true, - userId = "test_user" + userId = "test_user", + allowFallbackToL3 = true ) } } 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 053b6269..810f58bc 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 @@ -78,7 +78,8 @@ private constructor( val startInFullscreen: Boolean = false, val downloadMetadata: Map? = null, val offlineLicenseExpireTime: Long = DownloadConstants.FIFTEEN_DAYS_IN_SECONDS, - val userId: String? = null + val userId: String? = null, + val allowFallbackToL3: Boolean = false ) : Player by exoPlayer { val playbackSessionId = (1..6) @@ -440,7 +441,7 @@ private constructor( // PROVISIONING_FAILED, LICENSE_ACQUISITION_FAILED, SYSTEM_ERROR, // DISALLOWED_OPERATION, MediaCodec.CryptoException // DRM_LICENSE_EXPIRED is excluded — it has its own renewal path above. - if (isDrmContent && WidevinePlaybackLevelResolver.isDrmFallbackError(error)) { + if (allowFallbackToL3 && isDrmContent && WidevinePlaybackLevelResolver.isDrmFallbackError(error)) { if (!WidevinePlaybackLevelResolver.shouldForceL3()) { if (WidevinePlaybackLevelResolver.isDrmPermanentFailure(error)) { // e.g. PROVISIONING_FAILED: device certificate rejected by Google. @@ -1071,7 +1072,8 @@ private constructor( private fun createExoPlayer( context: Context, seekBackIncrementMs: Long = DEFAULT_SEEK_INCREMENT_MS, - seekForwardIncrementMs: Long = DEFAULT_SEEK_INCREMENT_MS + seekForwardIncrementMs: Long = DEFAULT_SEEK_INCREMENT_MS, + allowFallbackToL3: Boolean = false ): Pair { require(seekBackIncrementMs > 0) { "seekBackIncrementMs must be greater than 0, was $seekBackIncrementMs" } require(seekForwardIncrementMs > 0) { "seekForwardIncrementMs must be greater than 0, was $seekForwardIncrementMs" } @@ -1093,6 +1095,7 @@ private constructor( val drmSessionManagerProvider = WidevineDrmSessionManagerProvider( DownloadController.httpDataSourceFactory, + allowFallbackToL3 = allowFallbackToL3 ) val mediaSourceFactory = DefaultMediaSourceFactory(context) @@ -1130,9 +1133,10 @@ private constructor( offlineLicenseExpireTime: Long = DownloadConstants.FIFTEEN_DAYS_IN_SECONDS, seekBackIncrementMs: Long = DEFAULT_SEEK_INCREMENT_MS, seekForwardIncrementMs: Long = DEFAULT_SEEK_INCREMENT_MS, - userId: String? = null + userId: String? = null, + allowFallbackToL3: Boolean = false ): TPStreamsPlayer { - val (exo, trackSelector) = createExoPlayer(context, seekBackIncrementMs, seekForwardIncrementMs) + val (exo, trackSelector) = createExoPlayer(context, seekBackIncrementMs, seekForwardIncrementMs, allowFallbackToL3) return TPStreamsPlayer( context, exo, @@ -1146,7 +1150,8 @@ private constructor( startInFullscreen, downloadMetadata, offlineLicenseExpireTime, - userId) + userId, + allowFallbackToL3) } } diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/TestpressPlayer.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/TestpressPlayer.kt index 12968189..9458bce2 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/TestpressPlayer.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/TestpressPlayer.kt @@ -20,7 +20,8 @@ object TestpressPlayer { showDefaultCaptions: Boolean = false, startInFullscreen: Boolean = false, downloadMetadata: Map? = null, - offlineLicenseExpireTime: Long = DownloadConstants.FIFTEEN_DAYS_IN_SECONDS + offlineLicenseExpireTime: Long = DownloadConstants.FIFTEEN_DAYS_IN_SECONDS, + allowFallbackToL3: Boolean = false ): TPStreamsPlayer { return TPStreamsPlayer.create( context, @@ -32,7 +33,8 @@ object TestpressPlayer { showDefaultCaptions, startInFullscreen, downloadMetadata, - offlineLicenseExpireTime + offlineLicenseExpireTime, + allowFallbackToL3 = allowFallbackToL3 ) } } 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 index fc14e0d9..4e468772 100644 --- 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 @@ -24,6 +24,7 @@ import java.util.Objects @UnstableApi internal class WidevineDrmSessionManagerProvider( private val drmHttpDataSourceFactory: DataSource.Factory, + private val allowFallbackToL3: Boolean = false, ) : DrmSessionManagerProvider { private val lock = Any() @@ -39,7 +40,10 @@ internal class WidevineDrmSessionManagerProvider( private var l3Manager: DrmSessionManager? = null override fun get(mediaItem: MediaItem): DrmSessionManager { - if (!WidevinePlaybackLevelResolver.shouldForceL3()) { + val isNativeL3 = WidevinePlaybackLevelResolver.isNativeL3() + val shouldForceL3 = WidevinePlaybackLevelResolver.shouldForceL3() + + if (!isNativeL3 && (!allowFallbackToL3 || !shouldForceL3)) { return defaultProvider.get(mediaItem) } 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 index 979bf2a8..28391f24 100644 --- 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 @@ -6,26 +6,17 @@ 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.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. + * Resolves and caches which Widevine level to use for 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. + * 3. Default to native L1 for L1-capable devices without proactive background probing. + * 4. When playback fails with a DRM error and fallback is enabled, [forceL3ForSession] or + * [persistForceL3] is triggered during playback error recovery. */ internal object WidevinePlaybackLevelResolver { @@ -35,12 +26,6 @@ internal object WidevinePlaybackLevelResolver { 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() - @Volatile private var appContext: Context? = null @@ -50,12 +35,6 @@ internal object WidevinePlaybackLevelResolver { @Volatile private var nativeLevel: String? = null - enum class ProvisioningProbeResult { - SUCCESS, - PERMANENT_FAILURE, - TRANSIENT_FAILURE - } - fun initialize(context: Context) { if (cachedLevel != null) return @@ -75,27 +54,11 @@ internal object WidevinePlaybackLevelResolver { "Resolved playback level=L3, native=$nativeLevel, persistedL3=$forceL3Persisted", ) } else { - // Default immediately to native level (L1) without blocking the calling thread cachedLevel = WidevinePlaybackLevel.L1 Log.i( TAG, - "Defaulting playback level to L1 (native=$nativeLevel); launching background provisioning probe", + "Defaulting playback level to L1 (native=$nativeLevel)", ) - // Run provisioning probe asynchronously in background - CoroutineScope(Dispatchers.IO).launch { - when (runProvisioningProbe()) { - ProvisioningProbeResult.PERMANENT_FAILURE -> { - Log.w(TAG, "Provisioning probe permanently rejected device; persisting L3") - persistForceL3(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") - } - } - } } } } @@ -108,11 +71,13 @@ internal object WidevinePlaybackLevelResolver { fun getNativeWidevineLevel(): String? = nativeLevel + fun isNativeL3(): Boolean = nativeLevel == "L3" + fun shouldForceL3(): Boolean = getPlaybackLevelOrNull() == WidevinePlaybackLevel.L3 /** * Forces L3 for the current in-memory session only. - * Does NOT write to SharedPrefs — the next app launch re-runs the provisioning probe. + * Does NOT write to SharedPrefs — the next app launch defaults back to native level. * Use for transient DRM failures (license acquisition errors, system errors) where * the device may recover on the next launch. * For permanent failures (revoked keybox/certificate), use [persistForceL3] instead. @@ -189,58 +154,4 @@ internal object WidevinePlaybackLevelResolver { } } } - - 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.provisionRequest - val response = provisioningClient.newCall( - Request.Builder() - .url(request.defaultUrl) - .post(request.data.toRequestBody("application/octet-stream".toMediaType())) - .build(), - ).execute() - - if (!response.isSuccessful) { - val code = response.code - Log.w(TAG, "Widevine provisioning probe failed: HTTP $code") - // HTTP 4xx (e.g. 400, 403, 404) indicates explicit client/certificate rejection by the server. - // HTTP 5xx indicates transient server issue. - 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: Exception) { - Log.w(TAG, "Widevine provisioning probe failed: ${e.message}") - if (e is android.media.DeniedByServerException || e.javaClass.simpleName.contains("DeniedByServer")) { - ProvisioningProbeResult.PERMANENT_FAILURE - } else { - ProvisioningProbeResult.TRANSIENT_FAILURE - } - } finally { - mediaDrm?.let { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - it.close() - } else { - @Suppress("DEPRECATION") - it.release() - } - } - } - } } From 5c2b726ec79b63d26810e04816c05de78261aad4 Mon Sep 17 00:00:00 2001 From: Balamurugan-100 Date: Sun, 16 Aug 2026 12:27:44 +0530 Subject: [PATCH 07/11] refactor --- .../java/com/tpstreams/player/MainActivity.kt | 4 +- .../com/tpstreams/player/PlayerUIViewModel.kt | 6 +- .../tpstreams/player/TestPlayerActivity.kt | 4 +- .../com/tpstreams/player/TPStreamsPlayer.kt | 79 ++-------- .../java/com/tpstreams/player/TPStreamsSDK.kt | 11 +- .../com/tpstreams/player/TestpressPlayer.kt | 6 +- .../java/com/tpstreams/player/TestpressSDK.kt | 8 +- .../player/constants/PlaybackError.kt | 21 +-- .../tpstreams/player/data/AssetRepository.kt | 7 +- .../player/download/DownloadController.kt | 2 +- .../player/util/HttpResponseUtils.kt | 47 ------ .../player/util/NetworkDiagnosticsManager.kt | 18 +-- .../player/util/NetworkProbeRunner.kt | 93 +++-------- .../player/util/NetworkRecoveryHandler.kt | 3 - .../util/WidevineDrmSessionManagerProvider.kt | 15 +- .../util/WidevinePlaybackLevelResolver.kt | 144 ++++++++++++++++-- 16 files changed, 211 insertions(+), 257 deletions(-) delete mode 100644 tpstreams-android-player/src/main/java/com/tpstreams/player/util/HttpResponseUtils.kt 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/main/java/com/tpstreams/player/PlayerUIViewModel.kt b/app/src/main/java/com/tpstreams/player/PlayerUIViewModel.kt index bd6a65c6..814db748 100644 --- a/app/src/main/java/com/tpstreams/player/PlayerUIViewModel.kt +++ b/app/src/main/java/com/tpstreams/player/PlayerUIViewModel.kt @@ -21,8 +21,7 @@ class PlayerUIViewModel(application: Application) : AndroidViewModel(application assetId = assetId, accessToken = accessToken, shouldAutoPlay = true, - enableDownload = true, - allowFallbackToL3 = true + enableDownload = true ) } else { TPStreamsPlayer.create( @@ -31,8 +30,7 @@ class PlayerUIViewModel(application: Application) : AndroidViewModel(application accessToken = accessToken, shouldAutoPlay = true, enableDownload = true, - userId = "test_user", - allowFallbackToL3 = true + userId = "test_user" ) } } 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 810f58bc..ff2e3faa 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 @@ -78,8 +78,7 @@ private constructor( val startInFullscreen: Boolean = false, val downloadMetadata: Map? = null, val offlineLicenseExpireTime: Long = DownloadConstants.FIFTEEN_DAYS_IN_SECONDS, - val userId: String? = null, - val allowFallbackToL3: Boolean = false + val userId: String? = null ) : Player by exoPlayer { val playbackSessionId = (1..6) @@ -148,12 +147,6 @@ private constructor( @Volatile private var cdnHostname: String? = null - // The media URL the player is currently trying to load (with tp_token for AES - // streams). Used by the network probes to verify the actual CDN endpoint rather - // than just the hostname. - @Volatile - private var mediaUrl: String? = null - // Per-player decoder state (not global — avoids cross-player corruption) @Volatile private var decoderState = PlayerDecoderState() @@ -171,38 +164,8 @@ private constructor( retryPlayback = { retryPlayback() }, onDiagnosticsStarted = { _listener?.onNetworkDiagnosticsStarted() - }, - diagnosticHostProvider = ::resolveDiagnosticHost, - serverProbePathProvider = ::resolveServerProbePath - ) - - private fun resolveDiagnosticHost(): String { - return try { - val url = diagnosticAssetInfoUrl() - Uri.parse(url).host ?: NetworkDiagnosticsManager.DIAGNOSTIC_HOST_DEFAULT - } catch (_: Exception) { - NetworkDiagnosticsManager.DIAGNOSTIC_HOST_DEFAULT } - } - - private fun resolveServerProbePath(): String { - return try { - val path = Uri.parse(diagnosticAssetInfoUrl()).path - ?: return NetworkDiagnosticsManager.DEFAULT_SERVER_PROBE_PATH - SERVER_PROBE_PATH_REGEX.find(path)?.value - ?: NetworkDiagnosticsManager.DEFAULT_SERVER_PROBE_PATH - } catch (_: Exception) { - NetworkDiagnosticsManager.DEFAULT_SERVER_PROBE_PATH - } - } - - private fun diagnosticAssetInfoUrl(): String { - return TPStreamsSDK.apiService.assetInfoUrl( - TPStreamsSDK.requireOrgId(), - DIAGNOSTIC_DUMMY_ASSET_ID, - "" - ) - } + ) private val resumePlaybackManager: ResumePlaybackManager? = userId?.let { ResumePlaybackManager(this, assetId, it) } @@ -432,7 +395,7 @@ private constructor( } if (isNetworkError(error)) { - networkDiagnosticsManager.handleError(error.toError(), error, cdnHostname, decoderState, mediaUrl) + networkDiagnosticsManager.handleError(error.toError(), error, cdnHostname, decoderState) return } @@ -441,8 +404,11 @@ private constructor( // PROVISIONING_FAILED, LICENSE_ACQUISITION_FAILED, SYSTEM_ERROR, // DISALLOWED_OPERATION, MediaCodec.CryptoException // DRM_LICENSE_EXPIRED is excluded — it has its own renewal path above. - if (allowFallbackToL3 && isDrmContent && WidevinePlaybackLevelResolver.isDrmFallbackError(error)) { - if (!WidevinePlaybackLevelResolver.shouldForceL3()) { + if (WidevinePlaybackLevelResolver.isFallbackAllowed() && + isDrmContent && + WidevinePlaybackLevelResolver.isDrmFallbackError(error) + ) { + if (!WidevinePlaybackLevelResolver.isAlreadyOnL3PlaybackLevel()) { if (WidevinePlaybackLevelResolver.isDrmPermanentFailure(error)) { // e.g. PROVISIONING_FAILED: device certificate rejected by Google. // Persist L3 across all future sessions. @@ -527,11 +493,10 @@ private constructor( playerScope.launch(Dispatchers.Main) { exoPlayer.stop() - exoPlayer.setMediaItem(mediaItem) + exoPlayer.clearMediaItems() + val startPositionMs = if (!_isLiveStream && currentPosition > 0) currentPosition else C.TIME_UNSET + exoPlayer.setMediaItem(mediaItem, startPositionMs) exoPlayer.prepare() - if (!_isLiveStream) { - exoPlayer.seekTo(currentPosition) - } exoPlayer.playWhenReady = playWhenReady } return true @@ -573,12 +538,7 @@ private constructor( if (error == PlaybackError.NETWORK_CONNECTION_FAILED || error == PlaybackError.NETWORK_CONNECTION_TIMEOUT) { playerScope.launch { - networkDiagnosticsManager.handleError( - error, - cdnHostname = cdnHostname, - decoderState = decoderState, - mediaUrl = mediaUrl - ) + networkDiagnosticsManager.handleError(error, cdnHostname = cdnHostname, decoderState = decoderState) } } else { SentryLogger.logMessageWithEnrichment( @@ -616,7 +576,6 @@ private constructor( val result = MediaItemUtils.buildMediaItem(assetInfo, assetInfo.title, orgId, assetId, accessToken) drmLicenseUrl = result.drmLicenseUrl setSubtitleMetadata(result.subtitleMetadata) - mediaUrl = result.mediaItem.localConfiguration?.uri?.toString() playerScope.launch(Dispatchers.Main) { _isLiveStream = assetInfo.isLiveStream @@ -1060,8 +1019,6 @@ private constructor( private var activePlayerCount = 0 internal const val DEBUG_TAG = "PLAYBACK_ERROR_DEBUG" private const val DEFAULT_SEEK_INCREMENT_MS = 10000L - private const val DIAGNOSTIC_DUMMY_ASSET_ID = "00000000000" - private val SERVER_PROBE_PATH_REGEX = Regex("^/api/[^/]+/") private val client = OkHttpClient.Builder() .addInterceptor(ServerDateHeaderInterceptor()) .build() @@ -1072,8 +1029,7 @@ private constructor( private fun createExoPlayer( context: Context, seekBackIncrementMs: Long = DEFAULT_SEEK_INCREMENT_MS, - seekForwardIncrementMs: Long = DEFAULT_SEEK_INCREMENT_MS, - allowFallbackToL3: Boolean = false + seekForwardIncrementMs: Long = DEFAULT_SEEK_INCREMENT_MS ): Pair { require(seekBackIncrementMs > 0) { "seekBackIncrementMs must be greater than 0, was $seekBackIncrementMs" } require(seekForwardIncrementMs > 0) { "seekForwardIncrementMs must be greater than 0, was $seekForwardIncrementMs" } @@ -1095,7 +1051,6 @@ private constructor( val drmSessionManagerProvider = WidevineDrmSessionManagerProvider( DownloadController.httpDataSourceFactory, - allowFallbackToL3 = allowFallbackToL3 ) val mediaSourceFactory = DefaultMediaSourceFactory(context) @@ -1133,10 +1088,9 @@ private constructor( offlineLicenseExpireTime: Long = DownloadConstants.FIFTEEN_DAYS_IN_SECONDS, seekBackIncrementMs: Long = DEFAULT_SEEK_INCREMENT_MS, seekForwardIncrementMs: Long = DEFAULT_SEEK_INCREMENT_MS, - userId: String? = null, - allowFallbackToL3: Boolean = false + userId: String? = null ): TPStreamsPlayer { - val (exo, trackSelector) = createExoPlayer(context, seekBackIncrementMs, seekForwardIncrementMs, allowFallbackToL3) + val (exo, trackSelector) = createExoPlayer(context, seekBackIncrementMs, seekForwardIncrementMs) return TPStreamsPlayer( context, exo, @@ -1150,8 +1104,7 @@ private constructor( startInFullscreen, downloadMetadata, offlineLicenseExpireTime, - userId, - allowFallbackToL3) + userId) } } 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/TestpressPlayer.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/TestpressPlayer.kt index 9458bce2..12968189 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/TestpressPlayer.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/TestpressPlayer.kt @@ -20,8 +20,7 @@ object TestpressPlayer { showDefaultCaptions: Boolean = false, startInFullscreen: Boolean = false, downloadMetadata: Map? = null, - offlineLicenseExpireTime: Long = DownloadConstants.FIFTEEN_DAYS_IN_SECONDS, - allowFallbackToL3: Boolean = false + offlineLicenseExpireTime: Long = DownloadConstants.FIFTEEN_DAYS_IN_SECONDS ): TPStreamsPlayer { return TPStreamsPlayer.create( context, @@ -33,8 +32,7 @@ object TestpressPlayer { showDefaultCaptions, startInFullscreen, downloadMetadata, - offlineLicenseExpireTime, - allowFallbackToL3 = allowFallbackToL3 + offlineLicenseExpireTime ) } } 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/constants/PlaybackError.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/constants/PlaybackError.kt index 5687deab..4045c117 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/constants/PlaybackError.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/constants/PlaybackError.kt @@ -1,9 +1,6 @@ package com.tpstreams.player.constants import androidx.media3.common.PlaybackException -import com.tpstreams.player.util.findHttpResponseCode -import com.tpstreams.player.util.httpStatusUserMessage -import com.tpstreams.player.util.toPlaybackErrorFromHttpStatus enum class PlaybackError { NETWORK_CONNECTION_FAILED, @@ -20,7 +17,6 @@ enum class PlaybackError { INVALID_ACCESS_TOKEN_FOR_ASSETS, EXPIRED_ACCESS_TOKEN_FOR_ASSETS, INVALID_ACCESS_TOKEN_FOR_DRM_LICENSE, - SERVER_ERROR, LIVE_STREAM_NOT_STARTED, LIVE_STREAM_ENDED, UNSPECIFIED @@ -34,15 +30,10 @@ internal fun PlaybackException.toError(): PlaybackError { PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED -> PlaybackError.NETWORK_CONNECTION_FAILED PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT -> PlaybackError.NETWORK_CONNECTION_TIMEOUT PlaybackException.ERROR_CODE_DRM_LICENSE_ACQUISITION_FAILED -> PlaybackError.INVALID_ACCESS_TOKEN_FOR_DRM_LICENSE - PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> toBadHttpStatusError() else -> PlaybackError.UNSPECIFIED } } -private fun PlaybackException.toBadHttpStatusError(): PlaybackError { - return findHttpResponseCode()?.toPlaybackErrorFromHttpStatus() ?: PlaybackError.UNSPECIFIED -} - internal fun PlaybackException.getErrorMessage(playerId: String): String { return when (this.errorCode) { PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED -> @@ -51,10 +42,6 @@ internal fun PlaybackException.getErrorMessage(playerId: String): String { "The request took too long to process due to a slow or unstable network connection. Please try again.\n Player code: ${this.errorCode}. Player Id: $playerId" PlaybackException.ERROR_CODE_DRM_LICENSE_ACQUISITION_FAILED -> "There was an issue fetching the license key for this video. Please try again later.\n Player code: ${this.errorCode}. Player Id: $playerId" - PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> { - findHttpResponseCode()?.httpStatusUserMessage(playerId) - ?: "Oops! Something went wrong. Please contact support for assistance and provide details about the issue.\n Player code: ${this.errorCode}. Player Id: $playerId" - } PlaybackException.ERROR_CODE_DECODER_INIT_FAILED -> "

An error occurred while playing the video. Try restarting your device or playing another video. More help click here.
Player code: ${this.errorCode}. Player Id: $playerId

" PlaybackException.ERROR_CODE_DECODING_FAILED -> @@ -75,8 +62,12 @@ internal fun Exception.toPlaybackError(): PlaybackError { } internal fun Exception.getErrorMessage(playerId: String, responseCode: Int?): String { return when { - responseCode != null -> responseCode.httpStatusUserMessage(playerId) - ?: "Oops! Something went wrong. Please contact support for assistance and provide details about the issue.\n Error code: 5100. Player Id: $playerId" + responseCode == 404 -> + "The video is not available. Please try another one.\n Error code: 5001. Player Id: $playerId" + responseCode == 401 || responseCode == 403 -> + "Sorry, you don't have permission to access this video. Please check your credentials and try again.\n Error code: 5002. Player Id: $playerId" + responseCode != null && responseCode >= 500 -> + "We're sorry, but there's an issue on our server. Please try again later.\n Error code: 5005. Player Id: $playerId" this is java.io.IOException -> "Oops! It seems like you're not connected to the internet. Please check your connection and try again.\n Error code: 5004. Player Id: $playerId" this is LiveStreamNotStartedException -> diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/data/AssetRepository.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/data/AssetRepository.kt index 65acc7e2..93f64013 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/data/AssetRepository.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/data/AssetRepository.kt @@ -8,7 +8,6 @@ import com.tpstreams.player.constants.toPlaybackError import com.tpstreams.player.data.network.model.AssetInfo import com.tpstreams.player.util.ServerDateHeaderInterceptor import com.tpstreams.player.util.SentryLogger -import com.tpstreams.player.util.toPlaybackErrorFromHttpStatus import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -86,7 +85,11 @@ object AssetRepository { val errorPlayerId = SentryLogger.generatePlayerIdString() SentryLogger.logAPIException(Exception("API request failed with code: $code"), assetId, code, errorPlayerId, url, context = context) - val errorType = code.toPlaybackErrorFromHttpStatus() + val errorType = when (code) { + 404 -> PlaybackError.INVALID_ASSETS_ID + 401, 403 -> PlaybackError.INVALID_ACCESS_TOKEN_FOR_ASSETS + else -> PlaybackError.UNSPECIFIED + } val errorMessage = Exception().getErrorMessage(errorPlayerId, code) CoroutineScope(Dispatchers.Main).launch { 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 3013c16c..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 @@ -75,7 +75,7 @@ object DownloadController { if (isInitialized) return val appContext = context.applicationContext - WidevinePlaybackLevelResolver.initialize(appContext) + WidevinePlaybackLevelResolver.initialize(appContext, TPStreamsSDK.allowFallbackToL3) createNotificationChannel(appContext) diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/HttpResponseUtils.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/HttpResponseUtils.kt deleted file mode 100644 index 330757f3..00000000 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/HttpResponseUtils.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.tpstreams.player.util - -import androidx.media3.datasource.HttpDataSource -import com.tpstreams.player.constants.PlaybackError - -internal const val HTTP_STATUS_NOT_FOUND = 404 -internal const val HTTP_STATUS_UNAUTHORIZED = 401 -internal const val HTTP_STATUS_FORBIDDEN = 403 -internal const val HTTP_STATUS_SERVER_ERROR_MIN = 500 -internal const val HTTP_STATUS_SERVER_ERROR_MAX = 599 - -internal const val DISPLAY_ERROR_ASSET_NOT_FOUND = 5001 -internal const val DISPLAY_ERROR_ACCESS_DENIED = 5002 -internal const val DISPLAY_ERROR_SERVER = 5005 - -internal fun Throwable.findHttpResponseCode(): Int? { - var cause: Throwable? = this - while (cause != null) { - if (cause is HttpDataSource.InvalidResponseCodeException) return cause.responseCode - cause = cause.cause - } - return null -} - -internal fun Throwable.isAuthOrContentHttpFailure(): Boolean { - return when (findHttpResponseCode()) { - HTTP_STATUS_UNAUTHORIZED, HTTP_STATUS_FORBIDDEN, HTTP_STATUS_NOT_FOUND -> true - else -> false - } -} - -internal fun Int.toPlaybackErrorFromHttpStatus(): PlaybackError = when (this) { - HTTP_STATUS_NOT_FOUND -> PlaybackError.INVALID_ASSETS_ID - HTTP_STATUS_UNAUTHORIZED, HTTP_STATUS_FORBIDDEN -> PlaybackError.INVALID_ACCESS_TOKEN_FOR_ASSETS - in HTTP_STATUS_SERVER_ERROR_MIN..HTTP_STATUS_SERVER_ERROR_MAX -> PlaybackError.SERVER_ERROR - else -> PlaybackError.UNSPECIFIED -} - -internal fun Int.httpStatusUserMessage(playerId: String): String? = when (this) { - HTTP_STATUS_NOT_FOUND -> - "The video is not available. Please try another one.\n Error code: $DISPLAY_ERROR_ASSET_NOT_FOUND. Player Id: $playerId" - HTTP_STATUS_UNAUTHORIZED, HTTP_STATUS_FORBIDDEN -> - "Sorry, you don't have permission to access this video. Please check your credentials and try again.\n Error code: $DISPLAY_ERROR_ACCESS_DENIED. Player Id: $playerId" - in HTTP_STATUS_SERVER_ERROR_MIN..HTTP_STATUS_SERVER_ERROR_MAX -> - "We're sorry, but there's an issue on our server. Please try again later.\n Error code: $DISPLAY_ERROR_SERVER. Player Id: $playerId" - else -> null -} diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkDiagnosticsManager.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkDiagnosticsManager.kt index f3f9f10d..ec84926a 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkDiagnosticsManager.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkDiagnosticsManager.kt @@ -25,12 +25,11 @@ internal class NetworkDiagnosticsManager( private val listener: (PlaybackError, String, NetworkDiagnostics) -> Unit, private val retryPlayback: () -> Unit, private val onDiagnosticsStarted: (() -> Unit)? = null, - private val diagnosticHostProvider: () -> String = { DIAGNOSTIC_HOST_DEFAULT }, - private val serverProbePathProvider: () -> String = { DEFAULT_SERVER_PROBE_PATH }, + private val diagnosticHost: String = DIAGNOSTIC_HOST_DEFAULT ) { // Application context to avoid Activity leaks private val appContext: Context? = context?.applicationContext - private val probeRunner = NetworkProbeRunner(diagnosticHostProvider, serverProbePathProvider) + private val probeRunner = NetworkProbeRunner(diagnosticHost) private var networkErrorJob: Job? = null // Generation counter to invalidate in-flight probe results when a new error arrives @@ -52,8 +51,7 @@ internal class NetworkDiagnosticsManager( private const val NETWORK_ERROR_DEBOUNCE_MS = 500L private const val MAX_AUTO_RETRIES = 3 private const val AUTO_RETRY_DELAY_MS = 2000L - internal const val DIAGNOSTIC_HOST_DEFAULT = "app.tpstreams.com" - internal const val DEFAULT_SERVER_PROBE_PATH = "/api/v1/" + private const val DIAGNOSTIC_HOST_DEFAULT = "app.tpstreams.com" } fun onManualRetry() { @@ -89,13 +87,7 @@ internal class NetworkDiagnosticsManager( networkErrorJob?.cancel() } - fun handleError( - errorType: PlaybackError, - exoError: PlaybackException? = null, - cdnHostname: String? = null, - decoderState: PlayerDecoderState? = null, - mediaUrl: String? = null - ) { + fun handleError(errorType: PlaybackError, exoError: PlaybackException? = null, cdnHostname: String? = null, decoderState: PlayerDecoderState? = null) { networkErrorJob?.cancel() val attempt = ++probeGeneration hasPendingError = true @@ -108,7 +100,7 @@ internal class NetworkDiagnosticsManager( delay(NETWORK_ERROR_DEBOUNCE_MS) - val diagnostics = probeRunner.run(cdnHostname, mediaUrl) + val diagnostics = probeRunner.run(cdnHostname) yield() if (attempt != probeGeneration) return@launch diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkProbeRunner.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkProbeRunner.kt index e345ddf3..6fe12f8e 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkProbeRunner.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkProbeRunner.kt @@ -1,7 +1,6 @@ package com.tpstreams.player.util import android.util.Log -import com.tpstreams.player.TPStreamsSDK import com.tpstreams.player.constants.NetworkDiagnostics import kotlinx.coroutines.async import kotlinx.coroutines.Dispatchers @@ -15,26 +14,25 @@ import java.net.URL import javax.net.ssl.SSLException internal class NetworkProbeRunner( - private val diagnosticHostProvider: () -> String, - private val serverProbePathProvider: () -> String, + private val diagnosticHost: String ) { - suspend fun run(cdnHostname: String?, mediaUrl: String? = null, timeoutMs: Long = PROBE_TIMEOUT_MS): NetworkDiagnostics { + suspend fun run(cdnHostname: String?, timeoutMs: Long = PROBE_TIMEOUT_MS): NetworkDiagnostics { return withContext(Dispatchers.IO) { coroutineScope { withTimeoutOrNull(timeoutMs) { - runProbes(cdnHostname, mediaUrl) + runProbes(cdnHostname) } ?: createTimeoutFallback(cdnHostname) } } } - private suspend fun runProbes(cdnHostname: String?, mediaUrl: String?): NetworkDiagnostics = coroutineScope { + private suspend fun runProbes(cdnHostname: String?): NetworkDiagnostics = coroutineScope { val proxyConfigured = isProxyConfigured() val internetDef = async { probeInternet(proxyConfigured) } val dnsDef = async { probeDns(cdnHostname) } val serverDef = async { probeServer() } - val cdnDef = async { probeCdn(mediaUrl) } + val cdnDef = async { probeCdn(cdnHostname) } val (internet, internetLatency) = internetDef.await() val (dns, dnsLatency) = dnsDef.await() @@ -99,7 +97,6 @@ internal class NetworkProbeRunner( private fun isProxyConfigured(): Boolean { return try { - val diagnosticHost = diagnosticHostProvider() val uri = URI("https://$diagnosticHost") ProxySelector.getDefault()?.select(uri)?.any { it.type() != java.net.Proxy.Type.DIRECT @@ -129,7 +126,6 @@ internal class NetworkProbeRunner( } private fun probeDns(cdnHostname: String?): Pair { - val diagnosticHost = diagnosticHostProvider() val start = System.currentTimeMillis() // Resolve the API host and, if provided, the CDN hostname. // Both must resolve for the DNS check to pass — this catches cases @@ -157,13 +153,11 @@ internal class NetworkProbeRunner( } private fun probeServer(): Triple { - val diagnosticHost = diagnosticHostProvider() - val serverProbePath = serverProbePathProvider() val start = System.currentTimeMillis() // Hit the API root rather than the marketing/login page at the apex host. // The apex (https://$diagnosticHost/) returns a 302 to /accounts/login/ and // would create unnecessary access log noise on the auth server. - val probeUrl = "https://$diagnosticHost$serverProbePath" + val probeUrl = "https://$diagnosticHost$SERVER_PROBE_PATH" val (ok, detail) = try { val conn = (URL(probeUrl).openConnection() as HttpURLConnection).apply { connectTimeout = SINGLE_PROBE_TIMEOUT_MS @@ -176,8 +170,8 @@ internal class NetworkProbeRunner( val code = conn.responseCode logDebug("$probeUrl HEAD responseCode=$code") when { - code in HTTP_PROBE_SUCCESS_RANGE -> true to null - code in HTTP_PROBE_CLIENT_ERROR_RANGE -> true to "$code (rejected)" + code in 200..399 -> true to null + code in 400..499 -> true to "$code (rejected)" else -> false to "$code blocked" } } finally { @@ -197,20 +191,18 @@ internal class NetworkProbeRunner( return Triple(ok, detail, if (ok) System.currentTimeMillis() - start else null) } - private fun probeCdn(mediaUrl: String?): Triple { - val url = mediaUrl?.takeIf { it.isNotBlank() } - if (url == null) { - logDebug("no CDN media URL available, skipping probe") + private fun probeCdn(cdnHostname: String?): Triple { + if (cdnHostname == null) { + logDebug("no CDN hostname available, skipping probe") return Triple(null, null, null) } val start = System.currentTimeMillis() val (ok, detail) = try { - val headResult = executeCdnProbe(url, method = "HEAD") - if (headResult.shouldFallbackToGet) { - executeCdnProbe(url, method = "GET", rangeHeader = CDN_PROBE_RANGE_HEADER).result - } else { - headResult.result + java.net.Socket().use { socket -> + socket.connect(java.net.InetSocketAddress(cdnHostname, CDN_PORT), SINGLE_PROBE_TIMEOUT_MS) } + logDebug("CDN socket to $cdnHostname:$CDN_PORT succeeded") + true to null } catch (e: Exception) { val detail = when { e.message?.contains("timeout", ignoreCase = true) == true -> "timeout" @@ -219,59 +211,12 @@ internal class NetworkProbeRunner( e is javax.net.ssl.SSLException -> "ssl error" else -> "failed" } - logDebug("CDN HTTPS probe failed: ${e::class.simpleName}: ${e.message}") + logDebug("CDN socket to $cdnHostname:$CDN_PORT failed: ${e::class.simpleName}: ${e.message}") false to detail } return Triple(ok, detail, if (ok) System.currentTimeMillis() - start else null) } - private data class CdnProbeAttempt( - val result: Pair, - val shouldFallbackToGet: Boolean = false, - ) - - private fun executeCdnProbe( - url: String, - method: String, - rangeHeader: String? = null, - ): CdnProbeAttempt { - val conn = (URL(url).openConnection() as HttpURLConnection).apply { - connectTimeout = SINGLE_PROBE_TIMEOUT_MS - readTimeout = SINGLE_PROBE_TIMEOUT_MS - instanceFollowRedirects = true - requestMethod = method - setRequestProperty("User-Agent", USER_AGENT) - setRequestProperty("Cache-Control", "no-cache") - if (rangeHeader != null) { - setRequestProperty("Range", rangeHeader) - } - } - TPStreamsSDK.getAuthHeaders().forEach { (name, value) -> - conn.setRequestProperty(name, value) - } - return try { - val code = conn.responseCode - logDebug("CDN HTTPS $method probe to ${redactQuery(url)} → $code") - if (method == "HEAD" && code == HttpURLConnection.HTTP_BAD_METHOD) { - CdnProbeAttempt(result = false to "$code (method not allowed)", shouldFallbackToGet = true) - } else { - CdnProbeAttempt(result = evaluateCdnProbeStatus(code)) - } - } finally { - conn.disconnect() - } - } - - private fun evaluateCdnProbeStatus(code: Int): Pair = when { - code in HTTP_PROBE_SUCCESS_RANGE -> true to null - code in HTTP_PROBE_CLIENT_ERROR_RANGE -> false to "$code (rejected)" - else -> false to "$code blocked" - } - - private fun redactQuery(url: String): String { - return url.substringBefore('?') - } - private fun createTimeoutFallback(cdnHostname: String?): NetworkDiagnostics { logDebug("probes timed out after $PROBE_TIMEOUT_MS ms, using fallback") return NetworkDiagnostics( @@ -299,14 +244,14 @@ internal class NetworkProbeRunner( private const val INTERNET_PROBE_HTTP_HOST = "connectivitycheck.gstatic.com" // Canonical Android connectivity check path — returns HTTP 204 No Content. private const val INTERNET_PROBE_PATH = "/generate_204" + // Probes the API root, not the apex marketing/login page. + private const val SERVER_PROBE_PATH = "/api/v1/" private const val INTERNET_PROBE_HOST_PRIMARY = "8.8.8.8" private const val INTERNET_PROBE_HOST_FALLBACK = "1.1.1.1" private const val INTERNET_PROBE_PORT = 443 + private const val CDN_PORT = 443 private const val SINGLE_PROBE_TIMEOUT_MS = 3000 private const val PROBE_TIMEOUT_MS = 8000L private const val USER_AGENT = "TPStreamsSDK/1.0 (diagnostic-probe)" - private const val CDN_PROBE_RANGE_HEADER = "bytes=0-0" - private val HTTP_PROBE_SUCCESS_RANGE = 200..399 - private val HTTP_PROBE_CLIENT_ERROR_RANGE = 400..499 } } 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..678ae08c 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 @@ -10,7 +10,6 @@ import android.os.Handler import android.os.Looper import android.util.Log import androidx.media3.common.PlaybackException -import com.tpstreams.player.util.isAuthOrContentHttpFailure class NetworkRecoveryHandler(context: Context) { private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager @@ -97,8 +96,6 @@ 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 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/WidevineDrmSessionManagerProvider.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/WidevineDrmSessionManagerProvider.kt index 4e468772..82198e38 100644 --- 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 @@ -13,7 +13,7 @@ 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 androidx.media3.exoplayer.drm.UnsupportedDrmException +import android.util.Log import com.google.common.primitives.Ints import java.util.Objects @@ -24,7 +24,6 @@ import java.util.Objects @UnstableApi internal class WidevineDrmSessionManagerProvider( private val drmHttpDataSourceFactory: DataSource.Factory, - private val allowFallbackToL3: Boolean = false, ) : DrmSessionManagerProvider { private val lock = Any() @@ -40,10 +39,7 @@ internal class WidevineDrmSessionManagerProvider( private var l3Manager: DrmSessionManager? = null override fun get(mediaItem: MediaItem): DrmSessionManager { - val isNativeL3 = WidevinePlaybackLevelResolver.isNativeL3() - val shouldForceL3 = WidevinePlaybackLevelResolver.shouldForceL3() - - if (!isNativeL3 && (!allowFallbackToL3 || !shouldForceL3)) { + if (!WidevinePlaybackLevelResolver.shouldUseL3Drm()) { return defaultProvider.get(mediaItem) } @@ -82,6 +78,8 @@ internal class WidevineDrmSessionManagerProvider( } companion object { + private const val TAG = "WidevineDrmSessionManager" + internal val L3_EXO_MEDIA_DRM_PROVIDER = ExoMediaDrm.Provider { uuid -> try { FrameworkMediaDrm.newInstance(uuid).apply { @@ -89,7 +87,8 @@ internal class WidevineDrmSessionManagerProvider( setPropertyString("securityLevel", "L3") } } - } catch (_: UnsupportedDrmException) { + } catch (e: Exception) { + Log.w(TAG, "Failed to create L3 MediaDrm for uuid=$uuid", e) DummyExoMediaDrm() } } @@ -99,7 +98,7 @@ internal class WidevineDrmSessionManagerProvider( dataSourceFactory: DataSource.Factory, eventDispatcher: androidx.media3.exoplayer.drm.DrmSessionEventListener.EventDispatcher = androidx.media3.exoplayer.drm.DrmSessionEventListener.EventDispatcher() ): androidx.media3.exoplayer.drm.OfflineLicenseHelper { - if (!WidevinePlaybackLevelResolver.shouldForceL3()) { + if (!WidevinePlaybackLevelResolver.shouldUseL3Drm()) { return androidx.media3.exoplayer.drm.OfflineLicenseHelper.newWidevineInstance( licenseUri, false, 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 index 28391f24..460c8323 100644 --- 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 @@ -6,17 +6,26 @@ 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.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 DRM playback. + * 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. Default to native L1 for L1-capable devices without proactive background probing. - * 4. When playback fails with a DRM error and fallback is enabled, [forceL3ForSession] or - * [persistForceL3] is triggered during playback error recovery. + * 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 { @@ -26,6 +35,12 @@ internal object WidevinePlaybackLevelResolver { 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() + @Volatile private var appContext: Context? = null @@ -35,7 +50,17 @@ internal object WidevinePlaybackLevelResolver { @Volatile private var nativeLevel: String? = null - fun initialize(context: Context) { + @Volatile + private var allowFallbackToL3: Boolean = false + + enum class ProvisioningProbeResult { + SUCCESS, + PERMANENT_FAILURE, + TRANSIENT_FAILURE + } + + fun initialize(context: Context, allowFallbackToL3: Boolean = false) { + this.allowFallbackToL3 = this.allowFallbackToL3 || allowFallbackToL3 if (cachedLevel != null) return synchronized(this) { @@ -47,22 +72,46 @@ internal object WidevinePlaybackLevelResolver { nativeLevel = readNativeWidevineLevel() val forceL3Persisted = prefs.getBoolean(KEY_FORCE_L3, false) - if (forceL3Persisted || nativeLevel == "L3") { + if (nativeLevel == "L3" || (this.allowFallbackToL3 && forceL3Persisted)) { cachedLevel = WidevinePlaybackLevel.L3 Log.i( TAG, - "Resolved playback level=L3, native=$nativeLevel, persistedL3=$forceL3Persisted", + "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)", + "Defaulting playback level to L1 (native=$nativeLevel, fallbackAllowed=${this.allowFallbackToL3})", ) + if (this.allowFallbackToL3) { + CoroutineScope(Dispatchers.IO).launch { + when (runProvisioningProbe()) { + ProvisioningProbeResult.PERMANENT_FAILURE -> { + Log.w(TAG, "Provisioning probe permanently rejected device; persisting L3") + 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 } @@ -71,27 +120,26 @@ internal object WidevinePlaybackLevelResolver { fun getNativeWidevineLevel(): String? = nativeLevel - fun isNativeL3(): Boolean = nativeLevel == "L3" - - fun shouldForceL3(): Boolean = getPlaybackLevelOrNull() == WidevinePlaybackLevel.L3 + fun isAlreadyOnL3PlaybackLevel(): Boolean = getPlaybackLevelOrNull() == WidevinePlaybackLevel.L3 /** * Forces L3 for the current in-memory session only. - * Does NOT write to SharedPrefs — the next app launch defaults back to native level. + * Does NOT write to SharedPrefs — the next app launch re-runs the provisioning probe. * Use for transient DRM failures (license acquisition errors, system errors) where * the device may recover on the next launch. * For permanent failures (revoked keybox/certificate), use [persistForceL3] instead. */ fun forceL3ForSession() { + if (!allowFallbackToL3) return 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 - persistForceL3(prefs) - cachedLevel = WidevinePlaybackLevel.L3 + persistForceL3ToPrefs(prefs) Log.i(TAG, "Persisted L3 playback level for future sessions") } @@ -100,7 +148,8 @@ internal object WidevinePlaybackLevelResolver { * These warrant persisting L3 across all future sessions (e.g. revoked keybox). */ fun isDrmPermanentFailure(error: PlaybackException): Boolean { - return error.errorCode == PlaybackException.ERROR_CODE_DRM_PROVISIONING_FAILED + return error.errorCode == PlaybackException.ERROR_CODE_DRM_PROVISIONING_FAILED || + error.errorCode == PlaybackException.ERROR_CODE_DRM_DEVICE_REVOKED } /** @@ -112,6 +161,7 @@ internal object WidevinePlaybackLevelResolver { 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 @@ -130,8 +180,9 @@ internal object WidevinePlaybackLevelResolver { return false } - private fun persistForceL3(prefs: SharedPreferences) { + private fun persistForceL3ToPrefs(prefs: SharedPreferences) { prefs.edit().putBoolean(KEY_FORCE_L3, true).apply() + cachedLevel = WidevinePlaybackLevel.L3 } private fun readNativeWidevineLevel(): String? { @@ -154,4 +205,65 @@ internal object WidevinePlaybackLevelResolver { } } } + + 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.provisionRequest + 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() + + val response = provisioningClient.newCall(httpRequest).execute() + response.use { res -> + if (!res.isSuccessful) { + val code = res.code + Log.w(TAG, "Widevine provisioning probe failed: HTTP $code") + // HTTP 4xx (e.g. 400, 403, 404) indicates explicit client/certificate rejection by the server. + // HTTP 5xx indicates transient server issue. + return if (code in 400..499) { + ProvisioningProbeResult.PERMANENT_FAILURE + } else { + ProvisioningProbeResult.TRANSIENT_FAILURE + } + } + + val responseBody = res.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: Exception) { + Log.w(TAG, "Widevine provisioning probe failed: ${e.message}") + if (e is android.media.DeniedByServerException || e.javaClass.simpleName.contains("DeniedByServer")) { + ProvisioningProbeResult.PERMANENT_FAILURE + } else { + ProvisioningProbeResult.TRANSIENT_FAILURE + } + } finally { + mediaDrm?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + it.close() + } else { + @Suppress("DEPRECATION") + it.release() + } + } + } + } } From 28376989f1be72eb010e5c0015bdd3bdecf6256f Mon Sep 17 00:00:00 2001 From: Balamurugan-100 Date: Sun, 16 Aug 2026 12:31:09 +0530 Subject: [PATCH 08/11] refactor --- .../com/tpstreams/player/TPStreamsPlayer.kt | 50 +++++++++- .../player/constants/PlaybackError.kt | 21 +++-- .../tpstreams/player/data/AssetRepository.kt | 7 +- .../player/util/HttpResponseUtils.kt | 47 ++++++++++ .../player/util/NetworkDiagnosticsManager.kt | 18 +++- .../player/util/NetworkProbeRunner.kt | 93 +++++++++++++++---- .../player/util/NetworkRecoveryHandler.kt | 3 + 7 files changed, 201 insertions(+), 38 deletions(-) create mode 100644 tpstreams-android-player/src/main/java/com/tpstreams/player/util/HttpResponseUtils.kt 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 ff2e3faa..058769cf 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 @@ -147,6 +147,12 @@ private constructor( @Volatile private var cdnHostname: String? = null + // The media URL the player is currently trying to load (with tp_token for AES + // streams). Used by the network probes to verify the actual CDN endpoint rather + // than just the hostname. + @Volatile + private var mediaUrl: String? = null + // Per-player decoder state (not global — avoids cross-player corruption) @Volatile private var decoderState = PlayerDecoderState() @@ -164,9 +170,39 @@ private constructor( retryPlayback = { retryPlayback() }, onDiagnosticsStarted = { _listener?.onNetworkDiagnosticsStarted() - } + }, + diagnosticHostProvider = ::resolveDiagnosticHost, + serverProbePathProvider = ::resolveServerProbePath ) + private fun resolveDiagnosticHost(): String { + return try { + val url = diagnosticAssetInfoUrl() + Uri.parse(url).host ?: NetworkDiagnosticsManager.DIAGNOSTIC_HOST_DEFAULT + } catch (_: Exception) { + NetworkDiagnosticsManager.DIAGNOSTIC_HOST_DEFAULT + } + } + + private fun resolveServerProbePath(): String { + return try { + val path = Uri.parse(diagnosticAssetInfoUrl()).path + ?: return NetworkDiagnosticsManager.DEFAULT_SERVER_PROBE_PATH + SERVER_PROBE_PATH_REGEX.find(path)?.value + ?: NetworkDiagnosticsManager.DEFAULT_SERVER_PROBE_PATH + } catch (_: Exception) { + NetworkDiagnosticsManager.DEFAULT_SERVER_PROBE_PATH + } + } + + private fun diagnosticAssetInfoUrl(): String { + return TPStreamsSDK.apiService.assetInfoUrl( + TPStreamsSDK.requireOrgId(), + DIAGNOSTIC_DUMMY_ASSET_ID, + "" + ) + } + private val resumePlaybackManager: ResumePlaybackManager? = userId?.let { ResumePlaybackManager(this, assetId, it) } @@ -395,7 +431,7 @@ private constructor( } if (isNetworkError(error)) { - networkDiagnosticsManager.handleError(error.toError(), error, cdnHostname, decoderState) + networkDiagnosticsManager.handleError(error.toError(), error, cdnHostname, decoderState, mediaUrl) return } @@ -538,7 +574,12 @@ private constructor( if (error == PlaybackError.NETWORK_CONNECTION_FAILED || error == PlaybackError.NETWORK_CONNECTION_TIMEOUT) { playerScope.launch { - networkDiagnosticsManager.handleError(error, cdnHostname = cdnHostname, decoderState = decoderState) + networkDiagnosticsManager.handleError( + error, + cdnHostname = cdnHostname, + decoderState = decoderState, + mediaUrl = mediaUrl + ) } } else { SentryLogger.logMessageWithEnrichment( @@ -576,6 +617,7 @@ private constructor( val result = MediaItemUtils.buildMediaItem(assetInfo, assetInfo.title, orgId, assetId, accessToken) drmLicenseUrl = result.drmLicenseUrl setSubtitleMetadata(result.subtitleMetadata) + mediaUrl = result.mediaItem.localConfiguration?.uri?.toString() playerScope.launch(Dispatchers.Main) { _isLiveStream = assetInfo.isLiveStream @@ -1019,6 +1061,8 @@ private constructor( private var activePlayerCount = 0 internal const val DEBUG_TAG = "PLAYBACK_ERROR_DEBUG" private const val DEFAULT_SEEK_INCREMENT_MS = 10000L + private const val DIAGNOSTIC_DUMMY_ASSET_ID = "00000000000" + private val SERVER_PROBE_PATH_REGEX = Regex("^/api/[^/]+/") private val client = OkHttpClient.Builder() .addInterceptor(ServerDateHeaderInterceptor()) .build() diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/constants/PlaybackError.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/constants/PlaybackError.kt index 4045c117..5687deab 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/constants/PlaybackError.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/constants/PlaybackError.kt @@ -1,6 +1,9 @@ package com.tpstreams.player.constants import androidx.media3.common.PlaybackException +import com.tpstreams.player.util.findHttpResponseCode +import com.tpstreams.player.util.httpStatusUserMessage +import com.tpstreams.player.util.toPlaybackErrorFromHttpStatus enum class PlaybackError { NETWORK_CONNECTION_FAILED, @@ -17,6 +20,7 @@ enum class PlaybackError { INVALID_ACCESS_TOKEN_FOR_ASSETS, EXPIRED_ACCESS_TOKEN_FOR_ASSETS, INVALID_ACCESS_TOKEN_FOR_DRM_LICENSE, + SERVER_ERROR, LIVE_STREAM_NOT_STARTED, LIVE_STREAM_ENDED, UNSPECIFIED @@ -30,10 +34,15 @@ internal fun PlaybackException.toError(): PlaybackError { PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED -> PlaybackError.NETWORK_CONNECTION_FAILED PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT -> PlaybackError.NETWORK_CONNECTION_TIMEOUT PlaybackException.ERROR_CODE_DRM_LICENSE_ACQUISITION_FAILED -> PlaybackError.INVALID_ACCESS_TOKEN_FOR_DRM_LICENSE + PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> toBadHttpStatusError() else -> PlaybackError.UNSPECIFIED } } +private fun PlaybackException.toBadHttpStatusError(): PlaybackError { + return findHttpResponseCode()?.toPlaybackErrorFromHttpStatus() ?: PlaybackError.UNSPECIFIED +} + internal fun PlaybackException.getErrorMessage(playerId: String): String { return when (this.errorCode) { PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED -> @@ -42,6 +51,10 @@ internal fun PlaybackException.getErrorMessage(playerId: String): String { "The request took too long to process due to a slow or unstable network connection. Please try again.\n Player code: ${this.errorCode}. Player Id: $playerId" PlaybackException.ERROR_CODE_DRM_LICENSE_ACQUISITION_FAILED -> "There was an issue fetching the license key for this video. Please try again later.\n Player code: ${this.errorCode}. Player Id: $playerId" + PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> { + findHttpResponseCode()?.httpStatusUserMessage(playerId) + ?: "Oops! Something went wrong. Please contact support for assistance and provide details about the issue.\n Player code: ${this.errorCode}. Player Id: $playerId" + } PlaybackException.ERROR_CODE_DECODER_INIT_FAILED -> "

An error occurred while playing the video. Try restarting your device or playing another video. More help click here.
Player code: ${this.errorCode}. Player Id: $playerId

" PlaybackException.ERROR_CODE_DECODING_FAILED -> @@ -62,12 +75,8 @@ internal fun Exception.toPlaybackError(): PlaybackError { } internal fun Exception.getErrorMessage(playerId: String, responseCode: Int?): String { return when { - responseCode == 404 -> - "The video is not available. Please try another one.\n Error code: 5001. Player Id: $playerId" - responseCode == 401 || responseCode == 403 -> - "Sorry, you don't have permission to access this video. Please check your credentials and try again.\n Error code: 5002. Player Id: $playerId" - responseCode != null && responseCode >= 500 -> - "We're sorry, but there's an issue on our server. Please try again later.\n Error code: 5005. Player Id: $playerId" + responseCode != null -> responseCode.httpStatusUserMessage(playerId) + ?: "Oops! Something went wrong. Please contact support for assistance and provide details about the issue.\n Error code: 5100. Player Id: $playerId" this is java.io.IOException -> "Oops! It seems like you're not connected to the internet. Please check your connection and try again.\n Error code: 5004. Player Id: $playerId" this is LiveStreamNotStartedException -> diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/data/AssetRepository.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/data/AssetRepository.kt index 93f64013..65acc7e2 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/data/AssetRepository.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/data/AssetRepository.kt @@ -8,6 +8,7 @@ import com.tpstreams.player.constants.toPlaybackError import com.tpstreams.player.data.network.model.AssetInfo import com.tpstreams.player.util.ServerDateHeaderInterceptor import com.tpstreams.player.util.SentryLogger +import com.tpstreams.player.util.toPlaybackErrorFromHttpStatus import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -85,11 +86,7 @@ object AssetRepository { val errorPlayerId = SentryLogger.generatePlayerIdString() SentryLogger.logAPIException(Exception("API request failed with code: $code"), assetId, code, errorPlayerId, url, context = context) - val errorType = when (code) { - 404 -> PlaybackError.INVALID_ASSETS_ID - 401, 403 -> PlaybackError.INVALID_ACCESS_TOKEN_FOR_ASSETS - else -> PlaybackError.UNSPECIFIED - } + val errorType = code.toPlaybackErrorFromHttpStatus() val errorMessage = Exception().getErrorMessage(errorPlayerId, code) CoroutineScope(Dispatchers.Main).launch { diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/HttpResponseUtils.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/HttpResponseUtils.kt new file mode 100644 index 00000000..330757f3 --- /dev/null +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/HttpResponseUtils.kt @@ -0,0 +1,47 @@ +package com.tpstreams.player.util + +import androidx.media3.datasource.HttpDataSource +import com.tpstreams.player.constants.PlaybackError + +internal const val HTTP_STATUS_NOT_FOUND = 404 +internal const val HTTP_STATUS_UNAUTHORIZED = 401 +internal const val HTTP_STATUS_FORBIDDEN = 403 +internal const val HTTP_STATUS_SERVER_ERROR_MIN = 500 +internal const val HTTP_STATUS_SERVER_ERROR_MAX = 599 + +internal const val DISPLAY_ERROR_ASSET_NOT_FOUND = 5001 +internal const val DISPLAY_ERROR_ACCESS_DENIED = 5002 +internal const val DISPLAY_ERROR_SERVER = 5005 + +internal fun Throwable.findHttpResponseCode(): Int? { + var cause: Throwable? = this + while (cause != null) { + if (cause is HttpDataSource.InvalidResponseCodeException) return cause.responseCode + cause = cause.cause + } + return null +} + +internal fun Throwable.isAuthOrContentHttpFailure(): Boolean { + return when (findHttpResponseCode()) { + HTTP_STATUS_UNAUTHORIZED, HTTP_STATUS_FORBIDDEN, HTTP_STATUS_NOT_FOUND -> true + else -> false + } +} + +internal fun Int.toPlaybackErrorFromHttpStatus(): PlaybackError = when (this) { + HTTP_STATUS_NOT_FOUND -> PlaybackError.INVALID_ASSETS_ID + HTTP_STATUS_UNAUTHORIZED, HTTP_STATUS_FORBIDDEN -> PlaybackError.INVALID_ACCESS_TOKEN_FOR_ASSETS + in HTTP_STATUS_SERVER_ERROR_MIN..HTTP_STATUS_SERVER_ERROR_MAX -> PlaybackError.SERVER_ERROR + else -> PlaybackError.UNSPECIFIED +} + +internal fun Int.httpStatusUserMessage(playerId: String): String? = when (this) { + HTTP_STATUS_NOT_FOUND -> + "The video is not available. Please try another one.\n Error code: $DISPLAY_ERROR_ASSET_NOT_FOUND. Player Id: $playerId" + HTTP_STATUS_UNAUTHORIZED, HTTP_STATUS_FORBIDDEN -> + "Sorry, you don't have permission to access this video. Please check your credentials and try again.\n Error code: $DISPLAY_ERROR_ACCESS_DENIED. Player Id: $playerId" + in HTTP_STATUS_SERVER_ERROR_MIN..HTTP_STATUS_SERVER_ERROR_MAX -> + "We're sorry, but there's an issue on our server. Please try again later.\n Error code: $DISPLAY_ERROR_SERVER. Player Id: $playerId" + else -> null +} diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkDiagnosticsManager.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkDiagnosticsManager.kt index ec84926a..f3f9f10d 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkDiagnosticsManager.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkDiagnosticsManager.kt @@ -25,11 +25,12 @@ internal class NetworkDiagnosticsManager( private val listener: (PlaybackError, String, NetworkDiagnostics) -> Unit, private val retryPlayback: () -> Unit, private val onDiagnosticsStarted: (() -> Unit)? = null, - private val diagnosticHost: String = DIAGNOSTIC_HOST_DEFAULT + private val diagnosticHostProvider: () -> String = { DIAGNOSTIC_HOST_DEFAULT }, + private val serverProbePathProvider: () -> String = { DEFAULT_SERVER_PROBE_PATH }, ) { // Application context to avoid Activity leaks private val appContext: Context? = context?.applicationContext - private val probeRunner = NetworkProbeRunner(diagnosticHost) + private val probeRunner = NetworkProbeRunner(diagnosticHostProvider, serverProbePathProvider) private var networkErrorJob: Job? = null // Generation counter to invalidate in-flight probe results when a new error arrives @@ -51,7 +52,8 @@ internal class NetworkDiagnosticsManager( private const val NETWORK_ERROR_DEBOUNCE_MS = 500L private const val MAX_AUTO_RETRIES = 3 private const val AUTO_RETRY_DELAY_MS = 2000L - private const val DIAGNOSTIC_HOST_DEFAULT = "app.tpstreams.com" + internal const val DIAGNOSTIC_HOST_DEFAULT = "app.tpstreams.com" + internal const val DEFAULT_SERVER_PROBE_PATH = "/api/v1/" } fun onManualRetry() { @@ -87,7 +89,13 @@ internal class NetworkDiagnosticsManager( networkErrorJob?.cancel() } - fun handleError(errorType: PlaybackError, exoError: PlaybackException? = null, cdnHostname: String? = null, decoderState: PlayerDecoderState? = null) { + fun handleError( + errorType: PlaybackError, + exoError: PlaybackException? = null, + cdnHostname: String? = null, + decoderState: PlayerDecoderState? = null, + mediaUrl: String? = null + ) { networkErrorJob?.cancel() val attempt = ++probeGeneration hasPendingError = true @@ -100,7 +108,7 @@ internal class NetworkDiagnosticsManager( delay(NETWORK_ERROR_DEBOUNCE_MS) - val diagnostics = probeRunner.run(cdnHostname) + val diagnostics = probeRunner.run(cdnHostname, mediaUrl) yield() if (attempt != probeGeneration) return@launch diff --git a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkProbeRunner.kt b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkProbeRunner.kt index 6fe12f8e..e345ddf3 100644 --- a/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkProbeRunner.kt +++ b/tpstreams-android-player/src/main/java/com/tpstreams/player/util/NetworkProbeRunner.kt @@ -1,6 +1,7 @@ package com.tpstreams.player.util import android.util.Log +import com.tpstreams.player.TPStreamsSDK import com.tpstreams.player.constants.NetworkDiagnostics import kotlinx.coroutines.async import kotlinx.coroutines.Dispatchers @@ -14,25 +15,26 @@ import java.net.URL import javax.net.ssl.SSLException internal class NetworkProbeRunner( - private val diagnosticHost: String + private val diagnosticHostProvider: () -> String, + private val serverProbePathProvider: () -> String, ) { - suspend fun run(cdnHostname: String?, timeoutMs: Long = PROBE_TIMEOUT_MS): NetworkDiagnostics { + suspend fun run(cdnHostname: String?, mediaUrl: String? = null, timeoutMs: Long = PROBE_TIMEOUT_MS): NetworkDiagnostics { return withContext(Dispatchers.IO) { coroutineScope { withTimeoutOrNull(timeoutMs) { - runProbes(cdnHostname) + runProbes(cdnHostname, mediaUrl) } ?: createTimeoutFallback(cdnHostname) } } } - private suspend fun runProbes(cdnHostname: String?): NetworkDiagnostics = coroutineScope { + private suspend fun runProbes(cdnHostname: String?, mediaUrl: String?): NetworkDiagnostics = coroutineScope { val proxyConfigured = isProxyConfigured() val internetDef = async { probeInternet(proxyConfigured) } val dnsDef = async { probeDns(cdnHostname) } val serverDef = async { probeServer() } - val cdnDef = async { probeCdn(cdnHostname) } + val cdnDef = async { probeCdn(mediaUrl) } val (internet, internetLatency) = internetDef.await() val (dns, dnsLatency) = dnsDef.await() @@ -97,6 +99,7 @@ internal class NetworkProbeRunner( private fun isProxyConfigured(): Boolean { return try { + val diagnosticHost = diagnosticHostProvider() val uri = URI("https://$diagnosticHost") ProxySelector.getDefault()?.select(uri)?.any { it.type() != java.net.Proxy.Type.DIRECT @@ -126,6 +129,7 @@ internal class NetworkProbeRunner( } private fun probeDns(cdnHostname: String?): Pair { + val diagnosticHost = diagnosticHostProvider() val start = System.currentTimeMillis() // Resolve the API host and, if provided, the CDN hostname. // Both must resolve for the DNS check to pass — this catches cases @@ -153,11 +157,13 @@ internal class NetworkProbeRunner( } private fun probeServer(): Triple { + val diagnosticHost = diagnosticHostProvider() + val serverProbePath = serverProbePathProvider() val start = System.currentTimeMillis() // Hit the API root rather than the marketing/login page at the apex host. // The apex (https://$diagnosticHost/) returns a 302 to /accounts/login/ and // would create unnecessary access log noise on the auth server. - val probeUrl = "https://$diagnosticHost$SERVER_PROBE_PATH" + val probeUrl = "https://$diagnosticHost$serverProbePath" val (ok, detail) = try { val conn = (URL(probeUrl).openConnection() as HttpURLConnection).apply { connectTimeout = SINGLE_PROBE_TIMEOUT_MS @@ -170,8 +176,8 @@ internal class NetworkProbeRunner( val code = conn.responseCode logDebug("$probeUrl HEAD responseCode=$code") when { - code in 200..399 -> true to null - code in 400..499 -> true to "$code (rejected)" + code in HTTP_PROBE_SUCCESS_RANGE -> true to null + code in HTTP_PROBE_CLIENT_ERROR_RANGE -> true to "$code (rejected)" else -> false to "$code blocked" } } finally { @@ -191,18 +197,20 @@ internal class NetworkProbeRunner( return Triple(ok, detail, if (ok) System.currentTimeMillis() - start else null) } - private fun probeCdn(cdnHostname: String?): Triple { - if (cdnHostname == null) { - logDebug("no CDN hostname available, skipping probe") + private fun probeCdn(mediaUrl: String?): Triple { + val url = mediaUrl?.takeIf { it.isNotBlank() } + if (url == null) { + logDebug("no CDN media URL available, skipping probe") return Triple(null, null, null) } val start = System.currentTimeMillis() val (ok, detail) = try { - java.net.Socket().use { socket -> - socket.connect(java.net.InetSocketAddress(cdnHostname, CDN_PORT), SINGLE_PROBE_TIMEOUT_MS) + val headResult = executeCdnProbe(url, method = "HEAD") + if (headResult.shouldFallbackToGet) { + executeCdnProbe(url, method = "GET", rangeHeader = CDN_PROBE_RANGE_HEADER).result + } else { + headResult.result } - logDebug("CDN socket to $cdnHostname:$CDN_PORT succeeded") - true to null } catch (e: Exception) { val detail = when { e.message?.contains("timeout", ignoreCase = true) == true -> "timeout" @@ -211,12 +219,59 @@ internal class NetworkProbeRunner( e is javax.net.ssl.SSLException -> "ssl error" else -> "failed" } - logDebug("CDN socket to $cdnHostname:$CDN_PORT failed: ${e::class.simpleName}: ${e.message}") + logDebug("CDN HTTPS probe failed: ${e::class.simpleName}: ${e.message}") false to detail } return Triple(ok, detail, if (ok) System.currentTimeMillis() - start else null) } + private data class CdnProbeAttempt( + val result: Pair, + val shouldFallbackToGet: Boolean = false, + ) + + private fun executeCdnProbe( + url: String, + method: String, + rangeHeader: String? = null, + ): CdnProbeAttempt { + val conn = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = SINGLE_PROBE_TIMEOUT_MS + readTimeout = SINGLE_PROBE_TIMEOUT_MS + instanceFollowRedirects = true + requestMethod = method + setRequestProperty("User-Agent", USER_AGENT) + setRequestProperty("Cache-Control", "no-cache") + if (rangeHeader != null) { + setRequestProperty("Range", rangeHeader) + } + } + TPStreamsSDK.getAuthHeaders().forEach { (name, value) -> + conn.setRequestProperty(name, value) + } + return try { + val code = conn.responseCode + logDebug("CDN HTTPS $method probe to ${redactQuery(url)} → $code") + if (method == "HEAD" && code == HttpURLConnection.HTTP_BAD_METHOD) { + CdnProbeAttempt(result = false to "$code (method not allowed)", shouldFallbackToGet = true) + } else { + CdnProbeAttempt(result = evaluateCdnProbeStatus(code)) + } + } finally { + conn.disconnect() + } + } + + private fun evaluateCdnProbeStatus(code: Int): Pair = when { + code in HTTP_PROBE_SUCCESS_RANGE -> true to null + code in HTTP_PROBE_CLIENT_ERROR_RANGE -> false to "$code (rejected)" + else -> false to "$code blocked" + } + + private fun redactQuery(url: String): String { + return url.substringBefore('?') + } + private fun createTimeoutFallback(cdnHostname: String?): NetworkDiagnostics { logDebug("probes timed out after $PROBE_TIMEOUT_MS ms, using fallback") return NetworkDiagnostics( @@ -244,14 +299,14 @@ internal class NetworkProbeRunner( private const val INTERNET_PROBE_HTTP_HOST = "connectivitycheck.gstatic.com" // Canonical Android connectivity check path — returns HTTP 204 No Content. private const val INTERNET_PROBE_PATH = "/generate_204" - // Probes the API root, not the apex marketing/login page. - private const val SERVER_PROBE_PATH = "/api/v1/" private const val INTERNET_PROBE_HOST_PRIMARY = "8.8.8.8" private const val INTERNET_PROBE_HOST_FALLBACK = "1.1.1.1" private const val INTERNET_PROBE_PORT = 443 - private const val CDN_PORT = 443 private const val SINGLE_PROBE_TIMEOUT_MS = 3000 private const val PROBE_TIMEOUT_MS = 8000L private const val USER_AGENT = "TPStreamsSDK/1.0 (diagnostic-probe)" + private const val CDN_PROBE_RANGE_HEADER = "bytes=0-0" + private val HTTP_PROBE_SUCCESS_RANGE = 200..399 + private val HTTP_PROBE_CLIENT_ERROR_RANGE = 400..499 } } 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 678ae08c..9456ecd9 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 @@ -10,6 +10,7 @@ import android.os.Handler import android.os.Looper import android.util.Log import androidx.media3.common.PlaybackException +import com.tpstreams.player.util.isAuthOrContentHttpFailure class NetworkRecoveryHandler(context: Context) { private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager @@ -96,6 +97,8 @@ 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 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) From 96df476f23dda8d19f1d8f141c0740117ad63c31 Mon Sep 17 00:00:00 2001 From: Balamurugan-100 Date: Sun, 16 Aug 2026 13:41:40 +0530 Subject: [PATCH 09/11] fix: guard L3 fallback retry and improve Widevine resolver safety --- .../com/tpstreams/player/TPStreamsPlayer.kt | 40 ++++++- .../util/WidevineDrmSessionManagerProvider.kt | 2 +- .../util/WidevinePlaybackLevelResolver.kt | 109 +++++++++--------- 3 files changed, 90 insertions(+), 61 deletions(-) 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 058769cf..04b4eedc 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 @@ -140,6 +140,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()) @@ -244,6 +247,10 @@ private constructor( } init { + WidevinePlaybackLevelResolver.initialize( + context.applicationContext, + TPStreamsSDK.allowFallbackToL3, + ) debugLog("Player INIT - Instance created for assetId: $assetId") synchronized(TPStreamsPlayer::class.java) { activePlayerCount++ @@ -442,7 +449,8 @@ private constructor( // DRM_LICENSE_EXPIRED is excluded — it has its own renewal path above. if (WidevinePlaybackLevelResolver.isFallbackAllowed() && isDrmContent && - WidevinePlaybackLevelResolver.isDrmFallbackError(error) + WidevinePlaybackLevelResolver.isDrmFallbackError(error) && + !l3FallbackAttempted ) { if (!WidevinePlaybackLevelResolver.isAlreadyOnL3PlaybackLevel()) { if (WidevinePlaybackLevelResolver.isDrmPermanentFailure(error)) { @@ -523,21 +531,47 @@ private constructor( } 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(mediaItem, startPositionMs) + 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 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 index 82198e38..5e503e9b 100644 --- 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 @@ -88,7 +88,7 @@ internal class WidevineDrmSessionManagerProvider( } } } catch (e: Exception) { - Log.w(TAG, "Failed to create L3 MediaDrm for uuid=$uuid", e) + Log.e(TAG, "Failed to create L3 MediaDrm for uuid=$uuid; playback will fail at DRM init", e) DummyExoMediaDrm() } } 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 index 460c8323..abe0281d 100644 --- 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 @@ -2,12 +2,14 @@ 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 @@ -41,6 +43,8 @@ internal object WidevinePlaybackLevelResolver { .writeTimeout(10, TimeUnit.SECONDS) .build() + private val probeScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Volatile private var appContext: Context? = null @@ -53,6 +57,8 @@ internal object WidevinePlaybackLevelResolver { @Volatile private var allowFallbackToL3: Boolean = false + private val initLock = Any() + enum class ProvisioningProbeResult { SUCCESS, PERMANENT_FAILURE, @@ -60,10 +66,8 @@ internal object WidevinePlaybackLevelResolver { } fun initialize(context: Context, allowFallbackToL3: Boolean = false) { - this.allowFallbackToL3 = this.allowFallbackToL3 || allowFallbackToL3 - if (cachedLevel != null) return - - synchronized(this) { + synchronized(initLock) { + this.allowFallbackToL3 = this.allowFallbackToL3 || allowFallbackToL3 if (cachedLevel != null) return val applicationContext = context.applicationContext @@ -85,17 +89,22 @@ internal object WidevinePlaybackLevelResolver { "Defaulting playback level to L1 (native=$nativeLevel, fallbackAllowed=${this.allowFallbackToL3})", ) if (this.allowFallbackToL3) { - CoroutineScope(Dispatchers.IO).launch { + probeScope.launch { when (runProvisioningProbe()) { ProvisioningProbeResult.PERMANENT_FAILURE -> { Log.w(TAG, "Provisioning probe permanently rejected device; persisting L3") - persistForceL3ToPrefs(prefs) + 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") + Log.i( + TAG, + "Provisioning probe transient failure; maintaining L1 for this session without persisting L3", + ) } } } @@ -122,16 +131,11 @@ internal object WidevinePlaybackLevelResolver { fun isAlreadyOnL3PlaybackLevel(): Boolean = getPlaybackLevelOrNull() == WidevinePlaybackLevel.L3 - /** - * Forces L3 for the current in-memory session only. - * Does NOT write to SharedPrefs — the next app launch re-runs the provisioning probe. - * Use for transient DRM failures (license acquisition errors, system errors) where - * the device may recover on the next launch. - * For permanent failures (revoked keybox/certificate), use [persistForceL3] instead. - */ fun forceL3ForSession() { if (!allowFallbackToL3) return - cachedLevel = WidevinePlaybackLevel.L3 + synchronized(initLock) { + cachedLevel = WidevinePlaybackLevel.L3 + } Log.i(TAG, "L3 forced for current session only (not persisted)") } @@ -139,25 +143,16 @@ internal object WidevinePlaybackLevelResolver { if (!allowFallbackToL3) return val prefs = (context ?: appContext)?.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) ?: return - persistForceL3ToPrefs(prefs) - Log.i(TAG, "Persisted L3 playback level for future sessions") + synchronized(initLock) { + persistForceL3ToPrefs(prefs) + } } - /** - * Returns true for DRM errors that are permanent device-level failures. - * These warrant persisting L3 across all future sessions (e.g. revoked keybox). - */ fun isDrmPermanentFailure(error: PlaybackException): Boolean { return error.errorCode == PlaybackException.ERROR_CODE_DRM_PROVISIONING_FAILED || error.errorCode == PlaybackException.ERROR_CODE_DRM_DEVICE_REVOKED } - /** - * Returns true for any DRM error that should trigger an L3 fallback retry. - * Covers both permanent failures (provisioning) and transient ones - * (license acquisition, system errors, disallowed operations, hardware crypto failures). - * Excludes [PlaybackException.ERROR_CODE_DRM_LICENSE_EXPIRED] which has its own renewal path. - */ fun isDrmFallbackError(error: PlaybackException): Boolean { if (when (error.errorCode) { PlaybackException.ERROR_CODE_DRM_PROVISIONING_FAILED, @@ -181,8 +176,14 @@ internal object WidevinePlaybackLevelResolver { } 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? { @@ -195,24 +196,19 @@ internal object WidevinePlaybackLevelResolver { Log.w(TAG, "Failed to read native Widevine level: ${e.message}") null } finally { - mediaDrm?.let { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - it.close() - } else { - @Suppress("DEPRECATION") - it.release() - } - } + releaseMediaDrm(mediaDrm) } } private fun runProvisioningProbe(): ProvisioningProbeResult { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) return ProvisioningProbeResult.TRANSIENT_FAILURE + 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.provisionRequest + val request = mediaDrm.getProvisionRequest() val defaultUrl = request.defaultUrl if (defaultUrl.isNullOrBlank()) { Log.w(TAG, "Widevine provisioning probe failed: empty defaultUrl") @@ -224,13 +220,10 @@ internal object WidevinePlaybackLevelResolver { .post(request.data.toRequestBody("application/octet-stream".toMediaType())) .build() - val response = provisioningClient.newCall(httpRequest).execute() - response.use { res -> - if (!res.isSuccessful) { - val code = res.code + provisioningClient.newCall(httpRequest).execute().use { response -> + if (!response.isSuccessful) { + val code = response.code Log.w(TAG, "Widevine provisioning probe failed: HTTP $code") - // HTTP 4xx (e.g. 400, 403, 404) indicates explicit client/certificate rejection by the server. - // HTTP 5xx indicates transient server issue. return if (code in 400..499) { ProvisioningProbeResult.PERMANENT_FAILURE } else { @@ -238,7 +231,7 @@ internal object WidevinePlaybackLevelResolver { } } - val responseBody = res.body?.bytes() + val responseBody = response.body?.bytes() if (responseBody == null) { Log.w(TAG, "Widevine provisioning probe failed: empty response body") return ProvisioningProbeResult.TRANSIENT_FAILURE @@ -248,22 +241,24 @@ internal object WidevinePlaybackLevelResolver { 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}") - if (e is android.media.DeniedByServerException || e.javaClass.simpleName.contains("DeniedByServer")) { - ProvisioningProbeResult.PERMANENT_FAILURE - } else { - ProvisioningProbeResult.TRANSIENT_FAILURE - } + ProvisioningProbeResult.TRANSIENT_FAILURE } finally { - mediaDrm?.let { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - it.close() - } else { - @Suppress("DEPRECATION") - it.release() - } - } + 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() } } } From af781bdee330b8f1d8afb9e3920a39b3cb95d273 Mon Sep 17 00:00:00 2001 From: Balamurugan-100 Date: Sun, 16 Aug 2026 13:56:59 +0530 Subject: [PATCH 10/11] fix --- .../src/main/java/com/tpstreams/player/TPStreamsPlayer.kt | 3 +++ 1 file changed, 3 insertions(+) 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 04b4eedc..4c6e3ffe 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 @@ -661,6 +661,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") @@ -704,6 +705,7 @@ private constructor( val audioAttributes = buildAudioAttributes(isDrm) exoPlayer.setAudioAttributes(audioAttributes, true) + l3FallbackAttempted = false exoPlayer.setMediaItem(downloadedMediaItem) exoPlayer.prepare() isPrepared = true @@ -746,6 +748,7 @@ private constructor( exoPlayer.clearMediaItems() debugLog("MediaItem SET (Download) - ${mediaItem.mediaId}") + l3FallbackAttempted = false exoPlayer.setMediaItem(mediaItem) debugLog("Player PREPARE") exoPlayer.prepare() From 526a35bdac84ac159d50038e87090e7147a28acd Mon Sep 17 00:00:00 2001 From: Balamurugan-100 Date: Sun, 16 Aug 2026 16:40:13 +0530 Subject: [PATCH 11/11] fix: Network error --- .../com/tpstreams/player/TPStreamsPlayer.kt | 22 ++++++++++++++----- .../player/util/NetworkRecoveryHandler.kt | 1 + 2 files changed, 18 insertions(+), 5 deletions(-) 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 4c6e3ffe..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 @@ -129,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() @@ -437,11 +449,6 @@ private constructor( return } - if (isNetworkError(error)) { - networkDiagnosticsManager.handleError(error.toError(), error, cdnHostname, decoderState, mediaUrl) - return - } - // --- DRM fallback to L3 --- // Covers all DRM errors worth retrying at L3: // PROVISIONING_FAILED, LICENSE_ACQUISITION_FAILED, SYSTEM_ERROR, @@ -477,6 +484,11 @@ private constructor( } } + 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}") 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)