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 all 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
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)
rubeus90 marked this conversation as resolved.
Show resolved Hide resolved
}

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)
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)
}
}
}
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
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)
}
Loading
Loading