7 min read

Media3 + Widevine: The Licence Flow Nobody Documents

The six steps of a Widevine licence request, and the five places production breaks: runtime security-level downgrades, device provisioning, mid-playback licence expiry, leaked offline licences, and auth tokens that die before the session does.

AndroidMedia3ExoPlayerDRMWidevineAndroid TV
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Media3 makes DRM playback a four-line MediaItem config, which is why nobody learns the flow until it breaks. The failures are lifecycle failures, not playback failures: security level is a runtime value (an L1 device can be downgraded to L3 and your 4K ladder goes black), provisioning is a hidden network call that fails behind captive portals, licences expire mid-playback, offline licences leak if you don't release them on delete, and auth tokens expire before long sessions do. Log getDiagnosticInfo() and the vendor error code or your DRM bug reports are unactionable.

The six steps Media3 hides

val item = MediaItem.Builder()
    .setUri(manifestUrl)
    .setDrmConfiguration(
        MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID)
            .setLicenseUri(licenceUrl)
            .setLicenseRequestHeaders(mapOf("Authorization" to "Bearer $token"))
            .build()
    )
    .build()

That's the whole public API, and behind it:

  1. The manifest (DASH ContentProtection, HLS #EXT-X-KEY) points at a pssh box identifying the Widevine scheme and the key IDs.
  2. DefaultDrmSessionManager opens a MediaDrm session and asks the CDM for a key request — an opaque blob, not something you inspect or construct.
  3. Your MediaDrmCallback POSTs those bytes to the licence server, with whatever auth your business rules require.
  4. The server checks entitlement and returns a key response, also opaque.
  5. provideKeyResponse installs the keys; the session moves to STATE_OPENED_WITH_KEYS.
  6. The secure decoder decrypts frames. On L1 this happens in the TEE and the pixels never reach your process.

Nothing in steps 2–5 is inspectable. That's the design — and the reason the failure modes below are all about when and whether, never what.

Why the same build plays HD on one device and black on another

Security level is not a fixed device attribute. Query it:

val drm = MediaDrm(C.WIDEVINE_UUID)
val level = drm.getPropertyString("securityLevel")   // "L1" or "L3"
val hdcp = drm.getPropertyString("hdcpLevel")        // e.g. "HDCP-2.2"
drm.close()
  • L1 — keys and decryption in the TEE. Required by most studios for 1080p+.
  • L3 — software CDM. SD only, by licence policy.

A device that shipped L1 can end up on L3: a revoked certificate, a failed OTA, a rooted or unlocked bootloader, or an emulator. If your ABR ladder offers 1080p and the CDM is L3, the licence server refuses those keys and the user gets a black screen or a CryptoException — not a graceful downgrade.

Cap the ladder from the runtime value, before playback:

val maxHeight = if (level == "L1") 2160 else 480
trackSelector.setParameters(
    trackSelector.buildUponParameters().setMaxVideoSize(Int.MAX_VALUE, maxHeight)
)

Also check hdcpLevel for TV: HDCP 2.2 is commonly required for 4K, and an HDMI switch or a long cable in the user's living room can drop it. That produces the identical black screen with a completely different cause, which is why the diagnostic log matters more than the symptom.

Provisioning: the call you didn't know existed

Before a device can request any licence, its CDM needs a certificate from Google's provisioning service. Factory-reset devices, brand-new devices, and some cheap TV boxes arrive without one.

Media3 handles the round trip automatically — but it's a network call to a Google endpoint, and it fails in the environments where DRM problems already cluster: hotel captive portals, corporate VPNs with domain allow-lists, and regions with restricted connectivity. The symptom is NotProvisionedException surfacing as a DrmSessionException on first playback, and it repeats identically on retry, because retrying the licence request doesn't fix a missing certificate.

Detect it and say something true to the user — "this device needs to be set up for protected playback, check your connection" beats "playback error", and it prevents an entire class of support ticket where the user reinstalls the app three times.

Licences expire while the film is still playing

A licence has a duration, and it's often shorter than the content. A 60-minute licence on a 2-hour film stops playback at minute 60 with a key-expired error.

Media3 exposes the transition; listen for it and renew:

player.addAnalyticsListener(object : AnalyticsListener {
    override fun onDrmKeysExpired(eventTime: AnalyticsListener.EventTime) {
        // Session will fail imminently — refresh auth, then re-prepare
        refreshTokenAndReprepare()
    }
    override fun onDrmSessionManagerError(
        eventTime: AnalyticsListener.EventTime, error: Exception
    ) {
        log.drmFailure(error)
    }
})

Two related cases worth designing for up front: live streams with key rotation, where new keys arrive periodically and each rotation is a fresh licence request that can fail; and pause, where a long pause can outlive the licence, so resume needs the renewal path rather than a plain play().

Offline licences leak, silently

Downloads use a keySetId — a persisted handle to an offline licence:

val helper = OfflineLicenseHelper.newWidevineInstance(licenceUrl, httpFactory, drmListener)
val keySetId = helper.downloadLicense(format)     // store alongside the download

// Later, when the user deletes the download — DO NOT SKIP THIS
helper.releaseLicense(keySetId)

Skip releaseLicense and the licence remains counted against the user's device/download limit on the server. It's invisible in the app, invisible in QA, and shows up months later as "I can't download anything any more" from a customer who deletes and re-downloads a lot.

Two habits that pay: store the keySetId in the same transaction as the download record so they can't diverge, and check getLicenseDurationRemainingSec() before offline playback so an expired licence becomes a clear "renew this download" instead of a decode failure on a plane.

The token that dies before the session

The licence request carries your auth. A static header in DrmConfiguration is captured once. With short-lived tokens, key rotation on a live stream, or a session lasting hours, that header is stale by the time it's used again.

Use a callback that fetches the token per request:

class TokenAwareDrmCallback(
    private val licenceUrl: String,
    private val tokens: TokenProvider,
    private val factory: HttpDataSource.Factory,
) : MediaDrmCallback {
    override fun executeKeyRequest(uuid: UUID, request: ExoMediaDrm.KeyRequest): ByteArray {
        val delegate = HttpMediaDrmCallback(licenceUrl, factory)
        delegate.setKeyRequestProperty("Authorization", "Bearer ${tokens.fresh()}")
        return delegate.executeKeyRequest(uuid, request)
    }
    override fun executeProvisionRequest(uuid: UUID, request: ExoMediaDrm.ProvisionRequest) =
        HttpMediaDrmCallback(licenceUrl, factory).executeProvisionRequest(uuid, request)
}

tokens.fresh() should be a suspend-backed cache that refreshes on expiry — and single-flight, or a key rotation on a live stream will fire several refreshes at once.

Logging that makes DRM bugs actionable

MediaCodec.CryptoException: error 2 tells you nothing. Capture, on every DRM failure:

  • securityLevel and hdcpLevel at session start
  • MediaDrm.getPropertyString("version") (the CDM version)
  • device model, Android version, and whether it's a TV/emulator
  • for MediaDrmStateException: getDiagnosticInfo() and the vendor error code
  • the licence server's HTTP status and response body on non-2xx
  • content ID and key ID

Half of these can only be gathered at the moment of failure. Add them before you need them — a DRM issue that isn't reproducible in the office is otherwise unfixable, and DRM issues are rarely reproducible in the office.

The rule

DRM failures are licence-lifecycle failures, not playback failures. The lifecycle starts before the play button (provisioning, security level) and ends long after the last frame (offline licence release). Instrument the whole span, cap the ladder from the runtime CDM level rather than a device list, and treat every expiry — licence, token, HDCP handshake — as a code path you own.

Keep reading