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 +}