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.
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:
- The manifest (DASH
ContentProtection, HLS#EXT-X-KEY) points at apsshbox identifying the Widevine scheme and the key IDs. DefaultDrmSessionManageropens aMediaDrmsession and asks the CDM for a key request — an opaque blob, not something you inspect or construct.- Your
MediaDrmCallbackPOSTs those bytes to the licence server, with whatever auth your business rules require. - The server checks entitlement and returns a key response, also opaque.
provideKeyResponseinstalls the keys; the session moves toSTATE_OPENED_WITH_KEYS.- 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:
securityLevelandhdcpLevelat session startMediaDrm.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.