diff --git a/Armadillo/build.gradle b/Armadillo/build.gradle index 94a98b8..6625f71 100644 --- a/Armadillo/build.gradle +++ b/Armadillo/build.gradle @@ -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 { @@ -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") @@ -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 @@ -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" diff --git a/Armadillo/src/main/java/com/scribd/armadillo/Constants.kt b/Armadillo/src/main/java/com/scribd/armadillo/Constants.kt index 91af7fb..496c195 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/Constants.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/Constants.kt @@ -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 { diff --git a/Armadillo/src/main/java/com/scribd/armadillo/di/DownloadModule.kt b/Armadillo/src/main/java/com/scribd/armadillo/di/DownloadModule.kt index f07089a..96c3036 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/di/DownloadModule.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/di/DownloadModule.kt @@ -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 @@ -14,7 +15,6 @@ 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 @@ -22,8 +22,11 @@ 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 @@ -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 diff --git a/Armadillo/src/main/java/com/scribd/armadillo/di/PlaybackModule.kt b/Armadillo/src/main/java/com/scribd/armadillo/di/PlaybackModule.kt index 9ecaa41..308b230 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/di/PlaybackModule.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/di/PlaybackModule.kt @@ -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 @@ -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 @@ -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() } \ No newline at end of file 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..685cb77 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadEngine.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadEngine.kt @@ -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() } @@ -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, - 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, + @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() @@ -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") } 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..ca2a2a2 --- /dev/null +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DashDrmLicenseDownloader.kt @@ -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, + 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) + } + } +} \ 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..41c1486 --- /dev/null +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DrmLicenseDownloader.kt @@ -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, + drmInfo: DrmInfo, + ): DrmDownload + + /** + * Release a downloaded DRM license so it's no longer valid for usage. + */ + suspend fun releaseDrmLicense(drmDownload: DrmDownload) +} \ 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..d480586 --- /dev/null +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/OfflineDrmManager.kt @@ -0,0 +1,86 @@ +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.encryption.SecureStorage +import com.scribd.armadillo.error.DrmContentTypeUnsupportedException +import com.scribd.armadillo.extensions.toUri +import com.scribd.armadillo.models.AudioPlayable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manager class responsible for handling DRM downloading/persistence + */ +@Singleton +internal class OfflineDrmManager @Inject constructor( + private val context: Context, + private val secureStorage: SecureStorage, + private val dashDrmLicenseDownloader: DashDrmLicenseDownloader, +) { + companion object { + private const val TAG = "OfflineDrmManager" + } + + suspend fun downloadDrmLicenseForOffline(audioPlayable: AudioPlayable) { + withContext(Dispatchers.IO) { + audioPlayable.request.drmInfo?.let { drmInfo -> + val drmResult = when (@C.ContentType val type = Util.inferContentType(audioPlayable.request.url.toUri(), null)) { + C.TYPE_DASH -> dashDrmLicenseDownloader + else -> throw DrmContentTypeUnsupportedException(type) + }.downloadDrmLicense( + requestUrl = audioPlayable.request.url, + customRequestHeaders = audioPlayable.request.headers, + drmInfo = drmInfo, + ) + + // Persist DRM result, which includes the key ID that can be used to retrieve the offline license + secureStorage.saveDrmDownload(context, audioPlayable.request.url, drmResult) + Log.i(TAG, "DRM license ready for offline usage") + } + } + } + + suspend fun removeDownloadedDrmLicense(audioPlayable: AudioPlayable) { + withContext(Dispatchers.IO) { + audioPlayable.request.drmInfo?.let { drmInfo -> + secureStorage.getDrmDownload(context, audioPlayable.request.url, drmInfo.drmType)?.let { drmDownload -> + // Remove the persisted download info immediately so audio playback would stop using the offline license + secureStorage.removeDrmDownload(context, audioPlayable.request.url, drmInfo.drmType) + + // Release the DRM license + when (val type = drmDownload.audioType) { + C.TYPE_DASH -> dashDrmLicenseDownloader + else -> throw DrmContentTypeUnsupportedException(type) + }.releaseDrmLicense(drmDownload) + } + } + } + } + + suspend fun removeAllDownloadedDrmLicenses() { + withContext(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) + } + } + } + } + } +} \ No newline at end of file diff --git a/Armadillo/src/main/java/com/scribd/armadillo/encryption/ExoplayerEncryption.kt b/Armadillo/src/main/java/com/scribd/armadillo/encryption/ExoplayerEncryption.kt index 5cf6cff..648961c 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/encryption/ExoplayerEncryption.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/encryption/ExoplayerEncryption.kt @@ -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 { @@ -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()) } } \ No newline at end of file diff --git a/Armadillo/src/main/java/com/scribd/armadillo/encryption/SecureStorage.kt b/Armadillo/src/main/java/com/scribd/armadillo/encryption/SecureStorage.kt index 5cb6a4d..3f45e67 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/encryption/SecureStorage.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/encryption/SecureStorage.kt @@ -1,43 +1,86 @@ package com.scribd.armadillo.encryption import android.content.Context +import android.content.SharedPreferences +import android.util.Base64 import android.util.Log +import com.scribd.armadillo.Constants +import com.scribd.armadillo.models.DrmDownload +import com.scribd.armadillo.models.DrmType +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.nio.charset.StandardCharsets import java.security.MessageDigest +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton internal interface SecureStorage { fun downloadSecretKey(context: Context): ByteArray + fun saveDrmDownload(context: Context, audioUrl: String, drmDownload: DrmDownload) + fun getDrmDownload(context: Context, audioUrl: String, drmType: DrmType): DrmDownload? + fun getAllDrmDownloads(context: Context): Map + fun removeDrmDownload(context: Context, audioUrl: String, drmType: DrmType) + fun removeDrmDownload(context: Context, key: String) } -internal class ArmadilloSecureStorage : SecureStorage { - private companion object { +@Singleton +internal class ArmadilloSecureStorage @Inject constructor( + @Named(Constants.DI.STANDARD_STORAGE) private val standardStorage: SharedPreferences, + @Named(Constants.DI.DRM_DOWNLOAD_STORAGE) private val drmDownloadStorage: SharedPreferences, +) : SecureStorage { + companion object { const val DOWNLOAD_KEY = "download_key" const val STRING_LENGTH = 20 const val ALLOWED_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz0123456789" const val DEFAULT = "82YEDKqPBEqA2qAb4bUU" - const val LOCATION = "armadillo.storage" const val TAG = "SecureStorage" } override fun downloadSecretKey(context: Context): ByteArray { - val sharedPreferences = context.getSharedPreferences(LOCATION, Context.MODE_PRIVATE) - return if (sharedPreferences.contains(DOWNLOAD_KEY)) { - val storedKey = sharedPreferences.getString(DOWNLOAD_KEY, DEFAULT)!! - if(storedKey == DEFAULT){ - Log.e(TAG, "Storage Is Out of Alignment") - } - storedKey.toSecretByteArray - } else { - createRandomString().also { - sharedPreferences.edit().putString(DOWNLOAD_KEY, it).apply() - }.toSecretByteArray + return if (standardStorage.contains(DOWNLOAD_KEY)) { + val storedKey = standardStorage.getString(DOWNLOAD_KEY, DEFAULT)!! + if (storedKey == DEFAULT) { + Log.e(TAG, "Storage Is Out of Alignment") } + storedKey.toSecretByteArray + } else { + createRandomString().also { + standardStorage.edit().putString(DOWNLOAD_KEY, it).apply() + }.toSecretByteArray } + } private fun createRandomString(): String { - return (1..STRING_LENGTH) - .map { ALLOWED_CHARS.random() } - .joinToString("") - } + return (1..STRING_LENGTH) + .map { ALLOWED_CHARS.random() } + .joinToString("") + } + + override fun saveDrmDownload(context: Context, audioUrl: String, drmDownload: DrmDownload) { + val key = getDrmDownloadKey(audioUrl, drmDownload.drmType) + val value = Base64.encodeToString(Json.encodeToString(drmDownload).toByteArray(StandardCharsets.UTF_8), Base64.NO_WRAP) + drmDownloadStorage.edit().putString(key, value).apply() + } + + override fun getDrmDownload(context: Context, audioUrl: String, drmType: DrmType): DrmDownload? = + drmDownloadStorage.getString(getDrmDownloadKey(audioUrl, drmType), null)?.decodeToDrmDownload() + + override fun getAllDrmDownloads(context: Context): Map = + drmDownloadStorage.all.keys.mapNotNull { key -> + drmDownloadStorage.getString(key, null)?.let { drmResult -> + key to drmResult.decodeToDrmDownload() + } + }.toMap() + + override fun removeDrmDownload(context: Context, audioUrl: String, drmType: DrmType) { + drmDownloadStorage.edit().remove(getDrmDownloadKey(audioUrl, drmType)).apply() + } + + override fun removeDrmDownload(context: Context, key: String) { + drmDownloadStorage.edit().remove(key).apply() + } private val String.toSecretByteArray: ByteArray get() { @@ -47,4 +90,12 @@ internal class ArmadilloSecureStorage : SecureStorage { System.arraycopy(md.digest(), 0, keyBytes, 0, keyBytes.size) return keyBytes } + + private fun getDrmDownloadKey(audioUrl: String, drmType: DrmType) = + Base64.encodeToString(audioUrl.toSecretByteArray + drmType.name.toSecretByteArray, Base64.NO_WRAP) + + private fun String.decodeToDrmDownload(): DrmDownload = + Base64.decode(this, Base64.NO_WRAP).let { resultByteArray -> + Json.decodeFromString(String(resultByteArray, StandardCharsets.UTF_8)) + } } \ 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..f7c53d9 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/error/ArmadilloException.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/error/ArmadilloException.kt @@ -93,6 +93,10 @@ data class DownloadServiceLaunchedInBackground(val id: Int) : ArmadilloException override val errorCode = 304 } +data class UnexpectedDownloadException(val throwable: Throwable): ArmadilloException(exception = Exception(throwable)){ + override val errorCode = 305 +} + /** * Misc Errors */ @@ -127,4 +131,19 @@ 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 +} + +data class DrmPlaybackException(val exception: Exception) : ArmadilloException(exception) { + override val errorCode = 702 +} diff --git a/Armadillo/src/main/java/com/scribd/armadillo/models/Models.kt b/Armadillo/src/main/java/com/scribd/armadillo/models/Models.kt index 0d95498..2b1b472 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/models/Models.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/models/Models.kt @@ -3,6 +3,7 @@ package com.scribd.armadillo.models import android.os.Parcel import android.os.Parcelable import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.C.ContentType import com.scribd.armadillo.Milliseconds import com.scribd.armadillo.extensions.toPrint import com.scribd.armadillo.time.milliseconds @@ -143,6 +144,43 @@ data class Chapter( data class DrmInfo(val drmType: DrmType, val licenseServer: String, val drmHeaders: Map = emptyMap()) : Serializable +/** + * Object representing information about a downloaded DRM license. + * + * @param drmKeyId ID used to identify and to retrieve the downloaded license from local storage + * @param drmType the type of DRM solution of this license + * @param licenseServer the URL of the license server + */ +@kotlinx.serialization.Serializable +data class DrmDownload( + val drmKeyId: ByteArray, + val drmType: DrmType, + val licenseServer: String, + @ContentType val audioType: Int, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DrmDownload + + if (!drmKeyId.contentEquals(other.drmKeyId)) return false + if (drmType != other.drmType) return false + if (licenseServer != other.licenseServer) return false + if (audioType != other.audioType) return false + + return true + } + + override fun hashCode(): Int { + var result = drmKeyId.contentHashCode() + result = 31 * result + drmType.hashCode() + result = 31 * result + licenseServer.hashCode() + result = 31 * result + audioType + return result + } +} + enum class DrmType { WIDEVINE; diff --git a/Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DashMediaSourceGenerator.kt b/Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DashMediaSourceGenerator.kt index 2b17704..1b1c390 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DashMediaSourceGenerator.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DashMediaSourceGenerator.kt @@ -1,8 +1,7 @@ package com.scribd.armadillo.playback.mediasource import android.content.Context -import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.MediaItem.DrmConfiguration +import com.google.android.exoplayer2.drm.DrmSessionManagerProvider import com.google.android.exoplayer2.offline.Download import com.google.android.exoplayer2.offline.DownloadHelper import com.google.android.exoplayer2.source.MediaSource @@ -14,31 +13,24 @@ import javax.inject.Inject internal class DashMediaSourceGenerator @Inject constructor( private val mediaSourceHelper: HeadersMediaSourceHelper, - private val downloadTracker: DownloadTracker) : MediaSourceGenerator { + private val downloadTracker: DownloadTracker, + private val drmMediaSourceHelper: DrmMediaSourceHelper, + private val drmSessionManagerProvider: DrmSessionManagerProvider, +) : MediaSourceGenerator { override fun generateMediaSource(context: Context, request: AudioPlayable.MediaRequest): MediaSource { val dataSourceFactory = mediaSourceHelper.createDataSourceFactory(context, request) downloadTracker.getDownload(request.url.toUri())?.let { if (it.state != Download.STATE_FAILED) { - return DownloadHelper.createMediaSource(it.request, dataSourceFactory) + val mediaItem = drmMediaSourceHelper.createMediaItem(context = context, request = request, isDownload = true) + return DownloadHelper.createMediaSource(it.request, dataSourceFactory, drmSessionManagerProvider.get(mediaItem)) } } - val mediaItemBuilder = MediaItem.Builder() - .setUri(request.url) - - if (request.drmInfo != null) { - mediaItemBuilder.setDrmConfiguration( - DrmConfiguration.Builder(request.drmInfo.drmType.toExoplayerConstant()) - .setLicenseUri(request.drmInfo.licenseServer) - .setLicenseRequestHeaders(request.drmInfo.drmHeaders) - .build() - ) - } - + val mediaItem = drmMediaSourceHelper.createMediaItem(context = context, request = request, isDownload = false) return DashMediaSource.Factory(dataSourceFactory) - .createMediaSource(mediaItemBuilder.build()) + .createMediaSource(mediaItem) } override fun updateMediaSourceHeaders(request: AudioPlayable.MediaRequest) = mediaSourceHelper.updateMediaSourceHeaders(request) diff --git a/Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DrmMediaSourceHelper.kt b/Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DrmMediaSourceHelper.kt new file mode 100644 index 0000000..ef4841e --- /dev/null +++ b/Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DrmMediaSourceHelper.kt @@ -0,0 +1,52 @@ +package com.scribd.armadillo.playback.mediasource + +import android.content.Context +import com.google.android.exoplayer2.MediaItem +import com.scribd.armadillo.encryption.SecureStorage +import com.scribd.armadillo.error.DrmPlaybackException +import com.scribd.armadillo.models.AudioPlayable +import javax.inject.Inject +import javax.inject.Singleton + +/** + * This is a helper responsible for generating the correct media source for an audio request. + * + * This will apply the correct DRM-related information needed for content decryption (if the content is DRM-protected). + * In case of a download media (the content is either downloaded or being downloaded), this includes the DRM key ID used for retrieving + * the local DRM license (instead of fetching DRM license from the server). + */ +internal interface DrmMediaSourceHelper { + fun createMediaItem( + context: Context, + request: AudioPlayable.MediaRequest, + isDownload: Boolean, + ): MediaItem +} + +@Singleton +internal class DrmMediaSourceHelperImpl @Inject constructor(private val secureStorage: SecureStorage) : DrmMediaSourceHelper { + + override fun createMediaItem(context: Context, request: AudioPlayable.MediaRequest, isDownload: Boolean): MediaItem = + MediaItem.Builder() + .setUri(request.url) + .apply { + // Apply DRM config if content is DRM-protected + val drmConfig = request.drmInfo?.let { drmInfo -> + MediaItem.DrmConfiguration.Builder(drmInfo.drmType.toExoplayerConstant()) + .setLicenseUri(drmInfo.licenseServer) + .setLicenseRequestHeaders(drmInfo.drmHeaders) + .apply { + // If the content is a download content, use the saved offline DRM key id. + // This ID is needed to retrieve the local DRM license for content decryption. + if (isDownload) { + secureStorage.getDrmDownload(context, request.url, drmInfo.drmType)?.let { drmDownload -> + setKeySetId(drmDownload.drmKeyId) + } ?: throw DrmPlaybackException(IllegalStateException("No DRM key id saved for download content")) + } + } + .build() + } + setDrmConfiguration(drmConfig) + } + .build() +} diff --git a/gradle.properties b/gradle.properties index 021cb71..646debc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,6 +19,7 @@ RXANDROID_VERSION=2.0.1 DAGGER_VERSION=2.16 MAVEN_PUBLISH_VERSION=3.6.2 DOKKA_VERSION=1.6.10 +SERIALIZATON_VERSION=1.4.1 # Update package-list when updating build tools version BUILD_TOOLS_VERSION=29.0.3 android.useAndroidX=true