Android - Load DRM protected content in a WebView

A story of embeds

Embedding media content within a web page is a great way to drive user engagement, and enrich the content of a website. This pattern is usually implemented using iframes. Here is an example of an iframe that loads an encrypted episode from the Archetypes podcast, along with how it would render on a webpage:

<iframe src="https://open.spotify.com/embed/episode/7mGTaPDhOU72581l3H1eGQ" width="100%" height="352" allow="encrypted-media;\"></iframe>

iframes can also be embedded within mobile WebViews, offering an effective way of building a simple podcast application. Loading the above iframe in an Android WebView, can be done using the loadData method:

webView.loadData(
            "<iframe src=\"https://open.spotify.com/embed/episode/7mGTaPDhOU72581l3H1eGQ\" width=\"100%\" height=\"352\" allow=\"encrypted-media;\"></iframe>",
            "text/html",
            "UTF-8"
        )
)

Unfortunately, running this only loads the preview version of the episode:

Loading the iframe of an Archetypes episode inside an Android WebView, using the loadData method from WebView. The preview version of the episode is loaded instead of the full version.

The same outcome happens if the feature-policy encrypted-media; is omitted from the iframe:

<iframe src="https://open.spotify.com/embed/episode/7mGTaPDhOU72581l3H1eGQ" width="100%" height="352"></iframe>

Loading the iframe of an Archetypes episode inside the Chrome browser. The encrypted-media policy has been removed. As a result only the preview version is loaded instead of the full version.

The encrypted-media directive controls whether the document is allowed to use the Encrypted Media Extensions API (EME), which controls the playback of
DRM-protected content.

What is DRM?

DRM stands for Digital Right Management, and is a solution that handles the authorization and use of copyrighted digital assets. We can infer from this that the reason why the preview version of the episode is loaded is that it is protected by DRM.

From a high-level point of view, the DRM module (also known as CDM for Content Decryption Module), will fetch the encryption key from the licensing server and decrypt the content. On Android, the DRM that is used is called Widevine. It is possible to verify that the WebView supports Widevine by loading the following URL from the Shaka Player project: https://shaka-player-demo.appspot.com/support.html.

  "drm": {
    "org.w3.clearkey": {
      "persistentState": false
    },
    "com.microsoft.playready": null,
    "com.microsoft.playready.recommendation": null,
    "com.apple.fps.1_0": null,
    "com.apple.fps": null,
    "com.adobe.primetime": null,
    "com.widevine.alpha": {
      "persistentState": false
    }
  }

We can see from the above that the Widevine plugins are installed on the device. The fact that the full episode cannot be played shows that something is missing that allows decrypting the episode properly.

StackOverflow to the rescue

A quick search on StackOverflow teaches us that it is necessary to grant the RESOURCE_PROTECTED_MEDIA_ID permission to play DRM-protected content, which is done by overriding WebChromeClient#onPermissionRequest.

RESOURCE_PROTECTED_MEDIA_ID:
Resource belongs to protected media identifier. After the user grants this resource, the origin can use EME APIs to generate the license requests.

with (webView) {
  webChromeClient = object : WebChromeClient() {
    override fun onPermissionRequest(request: PermissionRequest?) {
      val resources = request?.resources
      resources?.forEach { resource ->
        if (
          PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID == resource
        ) {
             request.grant(resources)
             return
        }
      }
      super.onPermissionRequest(request)
    }
  }
  settings.javaScriptEnabled = true
  loadData(
    "<iframe  src=\"https://open.spotify.com/embed/episode/7mGTaPDhOU72581l3H1eGQ\" width=\"100%\" height=\"352\" allow=\"encrypted-media;\"></iframe>",
   "text/html",
    "UTF-8",
  )
}

Unfortunately, this still doesn't work. Setting a breakpoint within onPermissionRequest(), shows that it is not even executing. Looking at the console logs (which can be done by overriding onConsoleMessage), hints at something interesting:

DRM might not be available from unsecure contexts

Secure contexts

MDN docs define secure contexts as follows:

A secure context is a Window or Worker for which certain minimum standards of authentication and confidentiality are met. Many Web APIs and features are accessible only in a secure context. The primary goal of secure contexts is to prevent MITM attackers from accessing powerful APIs that could further compromise the victim of an attack.

EME APIs are not supported when using non-secure contexts. However, it is unclear at this point why WebViews don't provide a secure context. That is unless we look at the loadData documentation more closely:

Content loaded using this method will have a window.origin value of "null". This must not be considered to be a trusted origin by the application or by any JavaScript code running inside the WebView (for example, event sources in DOM event handlers or web messages), because malicious content can also create frames with a null origin. If you need to identify the main frame's origin in a trustworthy way, you should use loadDataWithBaseURL() with a valid HTTP or HTTPS base URL to set the origin.

What the documentation says is that content loaded with loadData will have an origin of null , otherwise known as an opaque origin. Instead, we need to load the iframe using loadDataWithBaseURL as that would allow us to set a secure origin (in this case: https://open.spotify.com). Following this recommendation loads the full episode:

Putting it all together

You can find the full working code that allows loading the encrypted content in the WebView, below:

with (webView) {
  webChromeClient = object : WebChromeClient() {
    override fun onPermissionRequest(request: PermissionRequest?) {
     val resources = request?.resources
     resources?.forEach { resource ->
      if (
          PermissionRequest.RESOURCE_PROTECTED_MEDIA_ID == resource
      ) {
           request.grant(resources)
           return
        }
     }
     super.onPermissionRequest(request)
   }
 }
 settings.javaScriptEnabled = true
 loadDataWithBaseURL(
   "https://open.spotify.com",
   "<iframe src=\"https://open.spotify.com/embed/episode/7mGTaPDhOU72581l3H1eGQ\" width=\"100%\" height=\"352\" allow=\"encrypted-media;\"></iframe>",
   "text/html",
   "UTF-8",
    null,
 )
}

An alternative solution consists in loading the URL directly:

webView.loadUrl("https://open.spotify.com/embed/episode/7mGTaPDhOU72581l3H1eGQ")

However, the iframe solution gives more freedom such as allowing you to pass custom attributes.

ChatGPT gets it wrong

Finally, for fun, I asked ChatGPT to solve this problem for me. In a disappointing move, it gave me the wrong answer:

To load an iframe with encrypted media in an Android WebView, you can use the WebView's loadData() method, which allows you to load a string of data into the WebView. The string should contain the HTML for the iframe, including the encrypted media source.

I have submitted a feedback.

Conclusion

When loading DRM-protected content in an Android WebView, make sure to:

  1. Grant the RESOURCE_PROTECTED_MEDIA_ID permission. This permission ensures the proper execution of the EME APIs.

  2. Load the content in a secured-context document. In particular, use loadDataWithBaseURL rather than loadData, as the latter does not provide a secure context.