Dev.to · 6 min read

Widevine license expiry on Android: four ways it shows up, and how to recover without a black screen

Widevine license expiry on Android: four ways it shows up, and how to recover without a black screen

Short-lived DRM licenses are a good idea. A license that lives for a few minutes limits the damage of a leaked key, lets you cut off a user who stopped paying, and is what many content owners now ask for. The catch is on the player side: the license will expire in the middle of playback, and your app has to get a new one without the viewer noticing. On Android with Media3 (ExoPlayer) and Widevine, we expected that to be one error to catch. It turned out to be four, and only one of them says "DRM" anywhere. This post is what we learned running long playback sessions with licenses that expire every 90 seconds. The easy face: a DRM error When things go by the book, an expired license surfaces as a PlaybackException with one of the DRM error codes (ERROR_CODE_DRM_*), usually caused by a MediaCodec.CryptoException. You catch it, fetch a fresh license, call prepare() again at the current position, and playback continues. If that were the whole story, this post would end here. Face 2: "Released state" On some devices the decoder notices the dead key first. The codec hits an asynchronous decrypt error and releases itself. What reaches your listener is not a DRM error but ERROR_CODE_DECODING_FAILED, with an IllegalStateException underneath whose message mentions the released state. If you only treat DRM codes as "license expired", this one ends playback with a generic decoder error, on a stream that would play fine with a fresh license. Face 3: a codec error in the DRM range Sometimes the codec's own error wins the race. You get ERROR_CODE_DECODING_FAILED again, but the cause chain contains a MediaCodec.CodecException whose error code sits in the DRM range (no license, license expired). Same root cause, third disguise. Face 4: an empty IllegalStateException The hardest one to spot: a bare IllegalStateException with an empty message, thrown from inside MediaCodec while queueing a secure input buffer. No text, no error code. The only clue is the stack: the top frame is in android.media.MediaCodec, and the item is DRM-protected. In our runs this showed up a few times per hundred expiries, which is exactly the rate that makes it look like a flaky device instead of a pattern. One gate for all four What worked for us is a single check that answers "could this be an expired license?" and only applies to DRM items. Clear content never takes this path, so a real decoder bug on a clear stream still surfaces as an error. // MediaCodec's ERROR_DRM_* range (frameworks/av MediaErrors.h) private val CODEC_DRM_ERRORS = -2099..-2000 private val RELEASED_STATE = Regex("(released|uninitialized) state", RegexOption.IGNORE_CASE) fun looksLikeLicenseExpiry(error: PlaybackException, isDrmItem: Boolean): Boolean { if (!isDrmItem) return false // Face 1: a real DRM error if (error.errorCode == PlaybackException.ERROR_CODE_DRM_LICENSE_EXPIRED || error.errorCode == PlaybackException.ERROR_CODE_DRM_SYSTEM_ERROR) return true if (error.errorCode != PlaybackException.ERROR_CODE_DECODING_FAILED) return false var cause: Throwable? = error.cause while (cause != null) { // Face 3: the codec's own DRM error code if (cause is MediaCodecDecoderException && cause.errorCode in CODEC_DRM_ERRORS) return true if (cause is MediaCodec.CodecException && cause.errorCode in CODEC_DRM_ERRORS) return true // Face 2: the codec released itself if (cause is IllegalStateException && RELEASED_STATE.containsMatchIn(cause.message.orEmpty())) return true // Face 4: bare IllegalStateException thrown by MediaCodec itself if (cause is IllegalStateException && cause !is MediaCodec.CodecException && cause.stackTrace.firstOrNull()?.className == "android.media.MediaCodec") return true cause = cause.cause } return false } Keep the check narrow: the goal is to catch these four shapes, not every decoder failure. Face 4 sounds broad, but it isn't: Media3 only wraps an exception as a decoding error when it came from the codec in the first place, so this matches nothing Media3 wouldn't already blame on the codec. Also leave out the DRM codes that mean the renewal itself failed (license acquisition failed, provisioning failed, device revoked): retrying those just delays the real error. The aftershock Here is the part that cost us the most time. You renew the license, call prepare(), and the very next thing you get is... another decoder error. The reason is how renderers are torn down. When the player disables its renderers after the error, a flush that throws can stop the loop before every renderer is reset. The audio renderer can be left holding a codec still bound to the dead DRM session. The first prepare() after renewal trips over it. The fix is not "retry forever". We allow a small number of plain re-prepares (two, in our case) right after a renewal, and we don't count them against the normal recovery budget. With that, the sequence "expiry → renewal → one or two aftershocks → playing" completes in well under a second, and the viewer sees nothing. Keep a budget Automatic recovery needs a limit, or a broken license server turns into a request storm. Ours allows two recoveries per 60 seconds per item (aftershocks excluded, as above). After that, the error goes to the app like any other, with the original cause attached so it can be shown or logged. A memory leak hiding behind renewals One more thing, if you target API 35+ with Media3 1.11: we saw native memory grow by a few megabytes on every reactive renewal. The cause was the loudness controller attached to audio codecs. On a codec bound to a dead DRM session, removing it from the controller can throw before the codec itself is released, so the codec is never freed. One leaked audio decoder per expiry adds up fast in a long session with short licenses. We worked around it with a custom codec adapter factory that skips the loudness controller for encrypted audio on API 35+, leaving clear content untouched. After that, native heap stayed flat across hundreds of renewals. What we'd tell our past selves Test expiry on purpose. A license that lasts 90 seconds will show you all of this in an hour; a license that lasts a day will show it to your users. Log the full cause chain, not just the error code. Three of the four faces are invisible at the top level. Treat "after a renewal" as its own state, with its own small retry allowance. Watch native memory, not only crashes. The leak never crashed anything; it just grew. We build OGPlayer, a video player SDK for Android, iOS, the web and smart TVs. More on DRM renewal in our guide.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Mobile & App News