From 73539d9bcde1b4b71fba653d1fcab2a7e9020dbd Mon Sep 17 00:00:00 2001 From: Hong Ngoc Nguyen Date: Mon, 5 Feb 2024 17:41:42 -0500 Subject: [PATCH] [APT-9577] Add DRM for downloads for offline usage : start downloading DRM license 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 --- .../armadillo/download/DownloadEngine.kt | 20 ++++-- .../download/drm/DashDrmLicenseDownloader.kt | 71 +++++++++++++++++++ .../download/drm/DrmLicenseDownloader.kt | 16 +++++ .../download/drm/OfflineDrmManager.kt | 21 ++++++ .../armadillo/error/ArmadilloException.kt | 11 +++ 5 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 Armadillo/src/main/java/com/scribd/armadillo/download/drm/DashDrmLicenseDownloader.kt create mode 100644 Armadillo/src/main/java/com/scribd/armadillo/download/drm/DrmLicenseDownloader.kt create mode 100644 Armadillo/src/main/java/com/scribd/armadillo/download/drm/OfflineDrmManager.kt diff --git a/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadEngine.kt b/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadEngine.kt index 3bb643c..2b4b3e5 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadEngine.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadEngine.kt @@ -15,6 +15,7 @@ import com.scribd.armadillo.Constants import com.scribd.armadillo.HeadersStore import com.scribd.armadillo.StateStore import com.scribd.armadillo.actions.ErrorAction +import com.scribd.armadillo.download.drm.OfflineDrmManager import com.scribd.armadillo.error.DownloadServiceLaunchedInBackground import com.scribd.armadillo.extensions.encodeInByteArray import com.scribd.armadillo.extensions.toUri @@ -39,15 +40,21 @@ internal interface DownloadEngine { * Starts the [DownloadService] when necessary */ @Singleton -internal class ExoplayerDownloadEngine @Inject constructor(private val context: Context, - private val downloadHeadersStore: HeadersStore, - private val downloadService: Class, - private val downloadManager: DownloadManager, - private val downloadTracker: DownloadTracker, - private val stateModifier: StateStore.Modifier) : DownloadEngine { +internal class ExoplayerDownloadEngine @Inject constructor( + private val context: Context, + private val downloadHeadersStore: HeadersStore, + private val downloadService: Class, + private val downloadManager: DownloadManager, + private val downloadTracker: DownloadTracker, + private val stateModifier: StateStore.Modifier, + private val offlineDrmManager: OfflineDrmManager, +) : DownloadEngine { override fun init() = downloadTracker.init() override fun download(audiobook: AudioPlayable) { + // Download DRM license for offline use + offlineDrmManager.downloadDrmForOffline(audiobook) + val downloadHelper = downloadHelper(context, audiobook.request) downloadHelper.prepare(object : DownloadHelper.Callback { @@ -96,6 +103,7 @@ internal class ExoplayerDownloadEngine @Inject constructor(private val context: C.TYPE_HLS, C.TYPE_DASH -> DownloadHelper.forMediaItem(context, mediaItem, renderersFactory, DefaultDataSource.Factory(context, dataSourceFactory)) + C.TYPE_OTHER -> DownloadHelper.forMediaItem(context, mediaItem) else -> throw IllegalStateException("Unsupported type: $type") } diff --git a/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DashDrmLicenseDownloader.kt b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DashDrmLicenseDownloader.kt new file mode 100644 index 0000000..efd438b --- /dev/null +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DashDrmLicenseDownloader.kt @@ -0,0 +1,71 @@ +package com.scribd.armadillo.download.drm + +import android.content.Context +import android.net.Uri +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.encryption.SecureStorage +import com.scribd.armadillo.error.DrmDownloadException +import com.scribd.armadillo.models.AudioPlayable +import com.scribd.armadillo.models.DrmType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +internal class DashDrmLicenseDownloader @Inject constructor(private val context: Context, + @Named(Constants.DI.GLOBAL_SCOPE) private val globalScope: CoroutineScope, + private val secureStorage: SecureStorage) : DrmLicenseDownloader { + + private val drmDataSourceFactory: DefaultHttpDataSource.Factory = DefaultHttpDataSource.Factory().setUserAgent(Constants.getUserAgent(context)) + private val audioDataSourceFactory = DefaultHttpDataSource.Factory().setUserAgent(Constants.getUserAgent(context)) + private val drmEventDispatcher = DrmSessionEventListener.EventDispatcher() + + override fun downloadDrmLicense(audiobook: AudioPlayable) { + globalScope.launch(Dispatchers.IO) { + // Fetch DRM license if the request contains a DRM + val drmKeyId = audiobook.request.drmInfo?.let { drmInfo -> + // Update data source for DRM license to add any DRM-specific request headers + drmDataSourceFactory.apply { + audiobook.request.drmInfo.drmHeaders.takeIf { it.isNotEmpty() }?.let { drmHeaders -> + setDefaultRequestProperties(drmHeaders) + } + } + // Update data source for audio to add custom headers + audioDataSourceFactory.apply { + audiobook.request.headers.takeIf { it.isNotEmpty() }?.let { audioHeaders -> + setDefaultRequestProperties(audioHeaders) + } + } + val audioDataSource = audioDataSourceFactory.createDataSource() + + // Create helper to download DRM license + val offlineHelper = when (drmInfo.drmType) { + DrmType.WIDEVINE -> OfflineLicenseHelper.newWidevineInstance(drmInfo.licenseServer, drmDataSourceFactory, drmEventDispatcher) + } + try { + val manifest = DashUtil.loadManifest(audioDataSource, Uri.parse(audiobook.request.url)) + val format = DashUtil.loadFormatWithDrmInitData(audioDataSource, manifest.getPeriod(0)) + format?.let { + offlineHelper.downloadLicense(format) + } + } catch (e: Exception) { + Log.e(DrmLicenseDownloader.TAG, "Failure to download DRM license for offline usage", e) + throw DrmDownloadException(e) + } + } + // Persist DRM license key ID. This ID will be used to retrieve the DRM key that is needed to decrypt DRM-protected content + drmKeyId?.also { + secureStorage.saveDrmKeyId(context, audiobook.request.url, DrmType.WIDEVINE.name, it) + Log.i(DrmLicenseDownloader.TAG, "DRM license downloaded and ready for offline usage") + } + } + } +} \ No newline at end of file diff --git a/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DrmLicenseDownloader.kt b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DrmLicenseDownloader.kt new file mode 100644 index 0000000..29beadb --- /dev/null +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DrmLicenseDownloader.kt @@ -0,0 +1,16 @@ +package com.scribd.armadillo.download.drm + +import com.scribd.armadillo.models.AudioPlayable + +/** + * This is a helper class responsible for downloading the DRM license (and persist it on the device) for a DRM-protected content. + * Aside from the DRM license itself, it's also responsible to persist the DRM key ID, which is the ID used to retrieve the downloaded + * license from storage for playback. + */ +internal interface DrmLicenseDownloader { + companion object { + const val TAG = "DrmLicenseDownloader" + } + + fun downloadDrmLicense(audiobook: AudioPlayable) +} \ No newline at end of file diff --git a/Armadillo/src/main/java/com/scribd/armadillo/download/drm/OfflineDrmManager.kt b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/OfflineDrmManager.kt new file mode 100644 index 0000000..5303383 --- /dev/null +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/OfflineDrmManager.kt @@ -0,0 +1,21 @@ +package com.scribd.armadillo.download.drm + +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.util.Util +import com.scribd.armadillo.error.DrmContentTypeUnsupportedException +import com.scribd.armadillo.extensions.toUri +import com.scribd.armadillo.models.AudioPlayable +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class OfflineDrmManager @Inject constructor( + private val dashDrmLicenseDownloader: DashDrmLicenseDownloader, +) { + fun downloadDrmForOffline(audiobook: AudioPlayable) { + when (@C.ContentType val type = Util.inferContentType(audiobook.request.url.toUri(), null)) { + C.TYPE_DASH -> dashDrmLicenseDownloader + else -> throw DrmContentTypeUnsupportedException(type) + }.downloadDrmLicense(audiobook) + } +} \ No newline at end of file diff --git a/Armadillo/src/main/java/com/scribd/armadillo/error/ArmadilloException.kt b/Armadillo/src/main/java/com/scribd/armadillo/error/ArmadilloException.kt index 26740d0..78fb6d3 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/error/ArmadilloException.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/error/ArmadilloException.kt @@ -127,4 +127,15 @@ data class UnknownRendererException(val exception: Exception) : ArmadilloExcepti override val errorCode: Int = 604 } +/** + * DRM errors + */ + +data class DrmContentTypeUnsupportedException(val contentType: Int) : ArmadilloException(exception = Exception()) { + override val errorCode = 700 +} + +data class DrmDownloadException(val exception: Exception) : ArmadilloException(exception) { + override val errorCode = 701 +}