-
Notifications
You must be signed in to change notification settings - Fork 5
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
Changes from 4 commits
08b4b77
1014f50
62d8813
d3ac619
6f451bf
88ff7bd
6f182fc
208aa3f
f7bbb7a
894f316
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
package com.scribd.armadillo.download.drm | ||
|
||
import android.content.Context | ||
import android.net.Uri | ||
import com.google.android.exoplayer2.C | ||
import com.google.android.exoplayer2.drm.DrmSessionEventListener | ||
import com.google.android.exoplayer2.drm.OfflineLicenseHelper | ||
import com.google.android.exoplayer2.source.dash.DashUtil | ||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource | ||
import com.google.android.exoplayer2.util.Log | ||
import com.scribd.armadillo.Constants | ||
import com.scribd.armadillo.error.DrmDownloadException | ||
import com.scribd.armadillo.models.DrmDownload | ||
import com.scribd.armadillo.models.DrmInfo | ||
import com.scribd.armadillo.models.DrmType | ||
import javax.inject.Inject | ||
import javax.inject.Singleton | ||
|
||
@Singleton | ||
internal class DashDrmLicenseDownloader @Inject constructor(context: Context) : DrmLicenseDownloader { | ||
|
||
private val drmDataSourceFactory = DefaultHttpDataSource.Factory().setUserAgent(Constants.getUserAgent(context)) | ||
private val audioDataSourceFactory = DefaultHttpDataSource.Factory().setUserAgent(Constants.getUserAgent(context)) | ||
private val drmEventDispatcher = DrmSessionEventListener.EventDispatcher() | ||
|
||
override suspend fun downloadDrmLicense( | ||
requestUrl: String, | ||
customRequestHeaders: Map<String, String>, | ||
drmInfo: DrmInfo, | ||
): DrmDownload { | ||
// Update data source for DRM license to add any DRM-specific request headers | ||
drmDataSourceFactory.addCustomHeaders(drmInfo.drmHeaders) | ||
// Update data source for audio to add custom headers | ||
audioDataSourceFactory.addCustomHeaders(customRequestHeaders) | ||
|
||
// Create helper to download DRM license | ||
val offlineHelper = when (drmInfo.drmType) { | ||
DrmType.WIDEVINE -> OfflineLicenseHelper.newWidevineInstance(drmInfo.licenseServer, drmDataSourceFactory, drmEventDispatcher) | ||
kschults marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
return try { | ||
val audioDataSource = audioDataSourceFactory.createDataSource() | ||
val manifest = DashUtil.loadManifest(audioDataSource, Uri.parse(requestUrl)) | ||
val format = DashUtil.loadFormatWithDrmInitData(audioDataSource, manifest.getPeriod(0)) | ||
format?.let { | ||
DrmDownload( | ||
drmKeyId = offlineHelper.downloadLicense(format), | ||
drmType = drmInfo.drmType, | ||
licenseServer = drmInfo.licenseServer, | ||
audioType = C.TYPE_DASH, | ||
) | ||
} ?: throw IllegalStateException("No media format retrieved for audio request") | ||
} catch (e: Exception) { | ||
Log.e(DrmLicenseDownloader.TAG, "Failure to download DRM license for offline usage", e) | ||
throw DrmDownloadException(e) | ||
} | ||
} | ||
|
||
override suspend fun releaseDrmLicense(drmDownload: DrmDownload) { | ||
val offlineHelper = when (drmDownload.drmType) { | ||
DrmType.WIDEVINE -> OfflineLicenseHelper.newWidevineInstance(drmDownload.licenseServer, drmDataSourceFactory, drmEventDispatcher) | ||
} | ||
try { | ||
offlineHelper.releaseLicense(drmDownload.drmKeyId) | ||
} catch (e: Exception) { | ||
Log.e(DrmLicenseDownloader.TAG, "Failure to release downloaded DRM license", e) | ||
throw DrmDownloadException(e) | ||
} | ||
} | ||
|
||
private fun DefaultHttpDataSource.Factory.addCustomHeaders(customHeaders: Map<String, String>) { | ||
customHeaders.takeIf { it.isNotEmpty() }?.let { headers -> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
setDefaultRequestProperties(headers) | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
package com.scribd.armadillo.download.drm | ||
|
||
import com.scribd.armadillo.models.DrmDownload | ||
import com.scribd.armadillo.models.DrmInfo | ||
|
||
/** | ||
* This is a helper class responsible for downloading the DRM license to local storage for a DRM-protected content. | ||
* This downloaded license can then be retrieved for offline usage using its key ID. | ||
*/ | ||
internal interface DrmLicenseDownloader { | ||
companion object { | ||
const val TAG = "DrmLicenseDownloader" | ||
} | ||
|
||
/** | ||
* Download and persist the DRM license | ||
* @return the key ID of the DRM license. This key ID can be used to fetch the license from storage | ||
*/ | ||
suspend fun downloadDrmLicense( | ||
requestUrl: String, | ||
customRequestHeaders: Map<String, String>, | ||
drmInfo: DrmInfo, | ||
): DrmDownload | ||
kschults marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
/** | ||
* Release a downloaded DRM license so it's no longer valid for usage. | ||
*/ | ||
suspend fun releaseDrmLicense(drmDownload: DrmDownload) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,89 @@ | ||
package com.scribd.armadillo.download.drm | ||
|
||
import android.content.Context | ||
import com.google.android.exoplayer2.C | ||
import com.google.android.exoplayer2.util.Log | ||
import com.google.android.exoplayer2.util.Util | ||
import com.scribd.armadillo.Constants | ||
import com.scribd.armadillo.encryption.SecureStorage | ||
import com.scribd.armadillo.error.DrmContentTypeUnsupportedException | ||
import com.scribd.armadillo.extensions.toUri | ||
import com.scribd.armadillo.models.AudioPlayable | ||
import kotlinx.coroutines.CoroutineScope | ||
import kotlinx.coroutines.Dispatchers | ||
import kotlinx.coroutines.launch | ||
import kotlinx.coroutines.supervisorScope | ||
import javax.inject.Inject | ||
import javax.inject.Named | ||
import javax.inject.Singleton | ||
|
||
/** | ||
* Manager class responsible for handling DRM downloading/persistence | ||
*/ | ||
@Singleton | ||
internal class OfflineDrmManager @Inject constructor( | ||
private val context: Context, | ||
@Named(Constants.DI.GLOBAL_SCOPE) private val globalScope: CoroutineScope, | ||
private val secureStorage: SecureStorage, | ||
private val dashDrmLicenseDownloader: DashDrmLicenseDownloader, | ||
) { | ||
companion object { | ||
private const val TAG = "OfflineDrmManager" | ||
} | ||
|
||
fun downloadDrmLicenseForOffline(audiobook: AudioPlayable) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
globalScope.launch(Dispatchers.IO) { | ||
audiobook.request.drmInfo?.let { drmInfo -> | ||
val drmResult = when (@C.ContentType val type = Util.inferContentType(audiobook.request.url.toUri(), null)) { | ||
C.TYPE_DASH -> dashDrmLicenseDownloader | ||
else -> throw DrmContentTypeUnsupportedException(type) | ||
}.downloadDrmLicense( | ||
requestUrl = audiobook.request.url, | ||
customRequestHeaders = audiobook.request.headers, | ||
drmInfo = drmInfo, | ||
) | ||
|
||
// Persist DRM result, which includes the key ID that can be used to retrieve the offline license | ||
secureStorage.saveDrmDownload(context, audiobook.request.url, drmResult) | ||
Log.i(TAG, "DRM license ready for offline usage") | ||
} | ||
} | ||
} | ||
|
||
fun removeDownloadedDrmLicense(audiobook: AudioPlayable) { | ||
globalScope.launch(Dispatchers.IO) { | ||
audiobook.request.drmInfo?.let { drmInfo -> | ||
secureStorage.getDrmDownload(context, audiobook.request.url, drmInfo.drmType)?.let { drmDownload -> | ||
// Remove the persisted download info immediately so audio playback would stop using the offline license | ||
secureStorage.removeDrmDownload(context, audiobook.request.url, drmInfo.drmType) | ||
|
||
// Release the DRM license | ||
when (val type = drmDownload.audioType) { | ||
C.TYPE_DASH -> dashDrmLicenseDownloader | ||
else -> throw DrmContentTypeUnsupportedException(type) | ||
}.releaseDrmLicense(drmDownload) | ||
} | ||
} | ||
} | ||
} | ||
|
||
fun removeAllDownloadedDrmLicenses() { | ||
globalScope.launch(Dispatchers.IO) { | ||
// Make sure that a removal fails, it won't affect the removal of other licenses | ||
supervisorScope { | ||
secureStorage.getAllDrmDownloads(context).forEach { drmDownloadPair -> | ||
launch { | ||
// Remove the persisted download info immediately so audio playback would stop using the offline license | ||
secureStorage.removeDrmDownload(context, drmDownloadPair.key) | ||
|
||
// Release the DRM license | ||
when (val type = drmDownloadPair.value.audioType) { | ||
C.TYPE_DASH -> dashDrmLicenseDownloader | ||
else -> throw DrmContentTypeUnsupportedException(type) | ||
}.releaseDrmLicense(drmDownloadPair.value) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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