Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add DRM support for download content #31

Merged
merged 10 commits into from
Feb 14, 2024
Merged

Conversation

rubeus90
Copy link
Contributor

When an audio content is downloaded and this content is protected by DRM, the DRM license needs to be downloaded and persisted alongside with the audio.
The license downloading is handled automatically by ExoPlayer's OfflineLicenseHelper. This tools downloads and persists the license, and provides us with a key ID which can be used to fetch the downloaded license.

During playback, instead of requesting the license from the server like in streaming mode, we provide the downloaded DRM license key ID to ExoPlayer so the local license can be used to decrypt the content.

When an audio download is removed, we also remove the key ID to the license, as well as remove the downloaded license locally and notify the server that the license should be released.

⚠️ Please note that DRM renewal handling has not yet been implemented in this PR. This will follow later as a separate PR.

…rage to it can store the DRM key id

Update SecureStorage to add new methods to save and fetch DRM key id.
This ID will later be used to fetch the DRM key needed to decrypt a DRM-protected content.
The ID is saved per content (per audio URL) and per DRM technology (we currently only support Widevine).

Also make SecureStorage injectable with Dagger.
…ense when download an MPEG-Dash audio

Create new helper DrmLicenseDownloader that is responsible for downloading the DRM license (and persist it on the device) for a DRM-protected content.
Once the DRM license is downloaded, we also persist separately its key ID, which is the ID used to retrieve the downloaded license from storage for playback.

When we start a new download, use this helper to download the DRM license to local storage. This currently only supports MPEG-Dash audio format
…PEG-Dash audio playback

Create new helper DrmMediaSourceHelper to generate the correct media item with DRM info depending on if the content is downloaded/being downloaded.

If the content is streaming, we only need to include the general DRM info so the DRM license can be fetched from the server to decrypt the encrypted content.
If the content is a download, we need to include all DRM info as well as the DRM key ID so the local DRM license can be used for decryption instead.
…wnloaded audio, release and remove the downloaded license and its download info
@kschults kschults self-assigned this Feb 13, 2024
}

private fun DefaultHttpDataSource.Factory.addCustomHeaders(customHeaders: Map<String, String>) {
customHeaders.takeIf { it.isNotEmpty() }?.let { headers ->
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason for this? Don't want to clear old ones if an empty map is sent in?

Copy link
Contributor Author

@rubeus90 rubeus90 Feb 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, at some point in my head I thought this was needed, but now I'm thinking again about it, it's not actually necessary. More than that, it's not great, since we might actually want to reset the headers by using an empty map.

I'm removing this logic

override fun init() = downloadTracker.init()

override fun download(audiobook: AudioPlayable) {
// Download DRM license for offline use
offlineDrmManager.downloadDrmLicenseForOffline(audiobook)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to block until the offline download is finished, or can they happen in parallel?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea they can happen in parallel. Updating this

}

override fun saveDrmDownload(context: Context, audioUrl: String, drmDownload: DrmDownload) {
context.getSharedPreferences(DOWNLOAD_FILENAME, Context.MODE_PRIVATE).also { sharedPrefs ->
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make more sense to inject the shared prefs into this class?


override fun getAllDrmDownloads(context: Context): Map<String, DrmDownload> =
context.getSharedPreferences(DOWNLOAD_FILENAME, Context.MODE_PRIVATE).let { sharedPrefs ->
sharedPrefs.all.keys.mapNotNull { key ->
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need to skip DOWNLOAD_KEY?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've put all the DRM download prefs into its own shared prefs file, so we don't need to exclude anything


override fun generateMediaSource(context: Context, request: AudioPlayable.MediaRequest): MediaSource {
val dataSourceFactory = mediaSourceHelper.createDataSourceFactory(context, request)

downloadTracker.getDownload(request.url.toUri())?.let {
if (it.state != Download.STATE_FAILED) {
return DownloadHelper.createMediaSource(it.request, dataSourceFactory)
val mediaItem = drmMediaSourceHelper.createMediaItem(context = context, request = request, isDownload = true)
return DownloadHelper.createMediaSource(it.request, dataSourceFactory, DefaultDrmSessionManagerProvider().get(mediaItem))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inject the provider maybe?


override fun generateMediaSource(context: Context, request: AudioPlayable.MediaRequest): MediaSource {
val dataSourceFactory = mediaSourceHelper.createDataSourceFactory(context, request)

downloadTracker.getDownload(request.url.toUri())?.let {
if (it.state != Download.STATE_FAILED) {
return DownloadHelper.createMediaSource(it.request, dataSourceFactory)
val mediaItem = drmMediaSourceHelper.createMediaItem(context = context, request = request, isDownload = true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to get bypassed for non-DRM content?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In DrmMediaSourceHelper, we check if the DRM object is non-null before adding anything DRM-related, so we're okay with not checking it here

Comment on lines +154 to +174
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false

other as DrmDownload

if (!drmKeyId.contentEquals(other.drmKeyId)) return false
if (drmType != other.drmType) return false
if (licenseServer != other.licenseServer) return false
if (audioType != other.audioType) return false

return true
}

override fun hashCode(): Int {
var result = drmKeyId.contentHashCode()
result = 31 * result + drmType.hashCode()
result = 31 * result + licenseServer.hashCode()
result = 31 * result + audioType
return result
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should get this by default with data class, why do we need to do it by hand?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's because this class contains a field of type ByteArray. Not generating equals and hashCode results in a warning

@SeanoAndroid SeanoAndroid self-assigned this Feb 13, 2024
private const val TAG = "OfflineDrmManager"
}

fun downloadDrmLicenseForOffline(audiobook: AudioPlayable) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NP: Should the param name be playable rather than audiobook? Since this is open source, it's possible users could have other DRM'd audio ¯\_(ツ)_/¯

Totally valid to keep it specific to our use case for clarity, though

… : accept an empty map of custom headers for the HTTP data source, as there's no reason to exclude that case
…oad/removal to execute in parallel to audio download/removal
…s : use injection for SharedPreferences and DrmSessionManagerProvider
…s : rename various params "audiobook" to "audioPlayable"
…pening in the download coroutines and dispatching the corresponding ErrorAction to notify the client app of the error
@rubeus90 rubeus90 merged commit e37aa5e into main Feb 14, 2024
6 checks passed
@rubeus90 rubeus90 deleted the ngoc/APT-9577-addDrmForDownloads branch February 14, 2024 21:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

3 participants