Skip to content

Commit

Permalink
Merge pull request #31 from scribd/ngoc/APT-9577-addDrmForDownloads
Browse files Browse the repository at this point in the history
Add DRM support for download content
  • Loading branch information
rubeus90 authored Feb 14, 2024
2 parents 5f4401b + 894f316 commit e37aa5e
Show file tree
Hide file tree
Showing 15 changed files with 478 additions and 68 deletions.
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
3 changes: 3 additions & 0 deletions Armadillo/src/main/java/com/scribd/armadillo/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ object Constants {
const val EXOPLAYER_CACHE_DIRECTORY = "exoplayer_cache_directory"

const val GLOBAL_SCOPE = "global_scope"

const val STANDARD_STORAGE = "standard_storage"
const val DRM_DOWNLOAD_STORAGE = "drm_download_storage"
}

internal object Exoplayer {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.scribd.armadillo.di

import android.content.Context
import android.content.SharedPreferences
import com.google.android.exoplayer2.offline.DownloadManager
import com.google.android.exoplayer2.offline.DownloadService
import com.google.android.exoplayer2.offline.DownloaderFactory
Expand All @@ -14,16 +15,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 +95,22 @@ internal class DownloadModule {
@Provides
fun exoplayerEncryption(exoplayerEncryption: ExoplayerEncryptionImpl): ExoplayerEncryption = exoplayerEncryption

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

@Singleton
@Provides
@Named(Constants.DI.STANDARD_STORAGE)
fun standardStorage(context: Context): SharedPreferences =
context.getSharedPreferences("armadillo.storage", Context.MODE_PRIVATE)

@Singleton
@Provides
@Named(Constants.DI.DRM_DOWNLOAD_STORAGE)
fun drmDownloadStorage(context: Context): SharedPreferences =
context.getSharedPreferences("armadillo.download.drm", Context.MODE_PRIVATE)

@Singleton
@Provides
fun downloadManagerFactory(downloadManagerFactory: ArmadilloDownloadManagerFactory): DownloadManagerFactory = downloadManagerFactory
Expand Down
12 changes: 12 additions & 0 deletions Armadillo/src/main/java/com/scribd/armadillo/di/PlaybackModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package com.scribd.armadillo.di

import android.app.Application
import android.content.Context
import com.google.android.exoplayer2.drm.DefaultDrmSessionManagerProvider
import com.google.android.exoplayer2.drm.DrmSessionManagerProvider
import com.scribd.armadillo.broadcast.ArmadilloNoisyReceiver
import com.scribd.armadillo.broadcast.ArmadilloNoisySpeakerReceiver
import com.scribd.armadillo.broadcast.ArmadilloNotificationDeleteReceiver
Expand All @@ -15,6 +17,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 +66,12 @@ internal class PlaybackModule {
@Provides
@Singleton
fun mediaSourceHelper(mediaSourceHelperImpl: HeadersMediaSourceHelperImpl): HeadersMediaSourceHelper = mediaSourceHelperImpl

@Provides
@Singleton
fun drmMediaSourceHelper(drmMediaSourceHelperImpl: DrmMediaSourceHelperImpl): DrmMediaSourceHelper = drmMediaSourceHelperImpl

@Provides
@Singleton
fun drmSessionManagerProvider(): DrmSessionManagerProvider = DefaultDrmSessionManagerProvider()
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,27 @@ 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.ArmadilloException
import com.scribd.armadillo.error.DownloadServiceLaunchedInBackground
import com.scribd.armadillo.error.UnexpectedDownloadException
import com.scribd.armadillo.extensions.encodeInByteArray
import com.scribd.armadillo.extensions.toUri
import com.scribd.armadillo.hasSnowCone
import com.scribd.armadillo.models.AudioPlayable
import com.scribd.armadillo.playback.createRenderersFactory
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton

internal interface DownloadEngine {
fun init()
fun download(audiobook: AudioPlayable)
fun removeDownload(audiobook: AudioPlayable)
fun download(audioPlayable: AudioPlayable)
fun removeDownload(audioPlayable: AudioPlayable)
fun removeAllDownloads()
fun updateProgress()
}
Expand All @@ -39,39 +46,67 @@ 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,
@Named(Constants.DI.GLOBAL_SCOPE) private val globalScope: CoroutineScope,
) : DownloadEngine {
private val errorHandler = CoroutineExceptionHandler { _, e ->
stateModifier.dispatch(ErrorAction(
error = e as? ArmadilloException ?: UnexpectedDownloadException(e)
))
}

override fun init() = downloadTracker.init()
override fun download(audioPlayable: AudioPlayable) {
globalScope.launch(errorHandler) {
launch {
// Download DRM license for offline use
offlineDrmManager.downloadDrmLicenseForOffline(audioPlayable)
}

override fun download(audiobook: AudioPlayable) {
val downloadHelper = downloadHelper(context, audiobook.request)
launch {
val downloadHelper = downloadHelper(context, audioPlayable.request)

downloadHelper.prepare(object : DownloadHelper.Callback {
override fun onPrepared(helper: DownloadHelper) {
val request = helper.getDownloadRequest(audiobook.id.encodeInByteArray())
try {
startDownload(context, request)
} catch (e: Exception) {
if (hasSnowCone() && e is ForegroundServiceStartNotAllowedException) {
stateModifier.dispatch(ErrorAction(DownloadServiceLaunchedInBackground(audiobook.id)))
} else {
stateModifier.dispatch(ErrorAction(com.scribd.armadillo.error.ArmadilloIOException(e)))
downloadHelper.prepare(object : DownloadHelper.Callback {
override fun onPrepared(helper: DownloadHelper) {
val request = helper.getDownloadRequest(audioPlayable.id.encodeInByteArray())
try {
startDownload(context, request)
} catch (e: Exception) {
if (hasSnowCone() && e is ForegroundServiceStartNotAllowedException) {
stateModifier.dispatch(ErrorAction(DownloadServiceLaunchedInBackground(audioPlayable.id)))
} else {
stateModifier.dispatch(ErrorAction(com.scribd.armadillo.error.ArmadilloIOException(e)))
}
}
}
}
}

override fun onPrepareError(helper: DownloadHelper, e: IOException) =
stateModifier.dispatch(ErrorAction(com.scribd.armadillo.error.ArmadilloIOException(e)))
})
override fun onPrepareError(helper: DownloadHelper, e: IOException) =
stateModifier.dispatch(ErrorAction(com.scribd.armadillo.error.ArmadilloIOException(e)))
})
}
}
}

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

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

override fun updateProgress() = downloadTracker.updateProgress()

Expand All @@ -96,6 +131,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,69 @@
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.setDefaultRequestProperties(drmInfo.drmHeaders)
// Update data source for audio to add custom headers
audioDataSourceFactory.setDefaultRequestProperties(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)
}
}
}
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 object containing information about the downloaded DRM license
*/
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)
}
Loading

0 comments on commit e37aa5e

Please sign in to comment.