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
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Armadillo/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ plugins {
// Extension of maven plugin that properly handles android dependencies
id 'digital.wup.android-maven-publish' version "${MAVEN_PUBLISH_VERSION}"
id 'org.jetbrains.dokka' version "${DOKKA_VERSION}" // Official Kotlin documentation engine - does both Kotlin and Java docs
id 'org.jetbrains.kotlin.plugin.serialization' version "${kotlin_version}"
}

android {
Expand Down Expand Up @@ -59,6 +60,8 @@ dependencies {
implementation "com.google.dagger:dagger:${DAGGER_VERSION}"
kapt "com.google.dagger:dagger-compiler:${DAGGER_VERSION}"
implementation 'androidx.media:media:1.6.0'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:${SERIALIZATON_VERSION}"

testImplementation "org.robolectric:robolectric:4.5.1"
testImplementation 'junit:junit:4.13.2'
testImplementation("org.assertj:assertj-core:3.10.0")
Expand All @@ -85,7 +88,7 @@ publishing {

publications {
android.libraryVariants.all { variant ->
"${variant.name.capitalize()}Aar" (MavenPublication) {
"${variant.name.capitalize()}Aar"(MavenPublication) {
from(components[variant.name])
groupId project.PACKAGE_NAME
version project.LIBRARY_VERSION
Expand All @@ -98,7 +101,7 @@ publishing {
}

android.libraryVariants.all { variant ->
"${variant.name.capitalize()}SnapshotAar" (MavenPublication) {
"${variant.name.capitalize()}SnapshotAar"(MavenPublication) {
from(components[variant.name])
groupId project.PACKAGE_NAME
version "${project.LIBRARY_VERSION}-SNAPSHOT"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,18 @@ import com.scribd.armadillo.download.ArmadilloDatabaseProviderImpl
import com.scribd.armadillo.download.ArmadilloDownloadManagerFactory
import com.scribd.armadillo.download.CacheManager
import com.scribd.armadillo.download.CacheManagerImpl
import com.scribd.armadillo.download.MaxAgeCacheEvictor
import com.scribd.armadillo.download.DefaultExoplayerDownloadService
import com.scribd.armadillo.download.DownloadEngine
import com.scribd.armadillo.download.DownloadManagerFactory
import com.scribd.armadillo.download.DownloadTracker
import com.scribd.armadillo.download.ExoplayerDownloadEngine
import com.scribd.armadillo.download.ExoplayerDownloadTracker
import com.scribd.armadillo.download.HeaderAwareDownloaderFactory
import com.scribd.armadillo.download.MaxAgeCacheEvictor
import com.scribd.armadillo.encryption.ArmadilloSecureStorage
import com.scribd.armadillo.encryption.ExoplayerEncryption
import com.scribd.armadillo.encryption.ExoplayerEncryptionImpl
import com.scribd.armadillo.encryption.SecureStorage
import com.scribd.armadillo.exoplayerExternalDirectory
import dagger.Module
import dagger.Provides
Expand Down Expand Up @@ -92,6 +94,10 @@ internal class DownloadModule {
@Provides
fun exoplayerEncryption(exoplayerEncryption: ExoplayerEncryptionImpl): ExoplayerEncryption = exoplayerEncryption

@Singleton
@Provides
fun secureStorage(secureStorage: ArmadilloSecureStorage): SecureStorage = secureStorage

@Singleton
@Provides
fun downloadManagerFactory(downloadManagerFactory: ArmadilloDownloadManagerFactory): DownloadManagerFactory = downloadManagerFactory
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import com.scribd.armadillo.playback.MediaMetadataCompatBuilderImpl
import com.scribd.armadillo.playback.PlaybackEngineFactoryHolder
import com.scribd.armadillo.playback.PlaybackStateBuilderImpl
import com.scribd.armadillo.playback.PlaybackStateCompatBuilder
import com.scribd.armadillo.playback.mediasource.DrmMediaSourceHelper
import com.scribd.armadillo.playback.mediasource.DrmMediaSourceHelperImpl
import com.scribd.armadillo.playback.mediasource.HeadersMediaSourceHelper
import com.scribd.armadillo.playback.mediasource.HeadersMediaSourceHelperImpl
import com.scribd.armadillo.playback.mediasource.MediaSourceRetriever
Expand Down Expand Up @@ -62,4 +64,8 @@ internal class PlaybackModule {
@Provides
@Singleton
fun mediaSourceHelper(mediaSourceHelperImpl: HeadersMediaSourceHelperImpl): HeadersMediaSourceHelper = mediaSourceHelperImpl

@Provides
@Singleton
fun drmMediaSourceHelper(drmMediaSourceHelperImpl: DrmMediaSourceHelperImpl): DrmMediaSourceHelper = drmMediaSourceHelperImpl
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<out DownloadService>,
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<out DownloadService>,
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.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


val downloadHelper = downloadHelper(context, audiobook.request)

downloadHelper.prepare(object : DownloadHelper.Callback {
Expand All @@ -69,9 +76,15 @@ internal class ExoplayerDownloadEngine @Inject constructor(private val context:
})
}

override fun removeDownload(audiobook: AudioPlayable) = downloadManager.removeDownload(audiobook.request.url)
override fun removeDownload(audiobook: AudioPlayable) {
downloadManager.removeDownload(audiobook.request.url)
offlineDrmManager.removeDownloadedDrmLicense(audiobook)
}

override fun removeAllDownloads() = downloadManager.removeAllDownloads()
override fun removeAllDownloads() {
downloadManager.removeAllDownloads()
offlineDrmManager.removeAllDownloadedDrmLicenses()
}

override fun updateProgress() = downloadTracker.updateProgress()

Expand All @@ -96,6 +109,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")
}
Expand Down
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)
}
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 ->
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

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

/**
* 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) {
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

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)
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ interface ExoplayerEncryption {
* This class provides the plumbing for encrypting downloaded content & then reading this encrypted content.
*/
@Singleton
internal class ExoplayerEncryptionImpl @Inject constructor(applicationContext: Context) : ExoplayerEncryption {
internal class ExoplayerEncryptionImpl @Inject constructor(applicationContext: Context,
secureStorage: SecureStorage) : ExoplayerEncryption {

private val secureStorage: SecureStorage = ArmadilloSecureStorage()
private val secret = secureStorage.downloadSecretKey(applicationContext)

override fun dataSinkFactory(downloadCache: Cache) = DataSink.Factory {
Expand All @@ -30,6 +30,6 @@ internal class ExoplayerEncryptionImpl @Inject constructor(applicationContext: C
}

override fun dataSourceFactory(upstream: DataSource.Factory) =
DataSource.Factory { AesCipherDataSource(secret, upstream.createDataSource()) }
DataSource.Factory { AesCipherDataSource(secret, upstream.createDataSource()) }

}
Loading
Loading