From 08b4b7792831eec490ce14a70ce35804edb67a9a Mon Sep 17 00:00:00 2001 From: Hong Ngoc Nguyen Date: Mon, 5 Feb 2024 17:30:16 -0500 Subject: [PATCH 01/10] [APT-9577] Add DRM for downloads for offline usage : update SecureStorage to it can store the DRM key id Update SecureStorage to add new methods to save and fetch DRM key id. This ID will later be used to fetch the DRM key needed to decrypt a DRM-protected content. The ID is saved per content (per audio URL) and per DRM technology (we currently only support Widevine). Also make SecureStorage injectable with Dagger. --- Armadillo/build.gradle | 7 +- .../com/scribd/armadillo/di/DownloadModule.kt | 8 +- .../encryption/ExoplayerEncryption.kt | 6 +- .../armadillo/encryption/SecureStorage.kt | 90 +++++++++++++++---- .../com/scribd/armadillo/models/Models.kt | 31 +++++++ gradle.properties | 1 + 6 files changed, 121 insertions(+), 22 deletions(-) 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/di/DownloadModule.kt b/Armadillo/src/main/java/com/scribd/armadillo/di/DownloadModule.kt index f07089a..170f50f 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/di/DownloadModule.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/di/DownloadModule.kt @@ -14,7 +14,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 +21,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 +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 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..ce23b25 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/encryption/SecureStorage.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/encryption/SecureStorage.kt @@ -1,44 +1,94 @@ package com.scribd.armadillo.encryption import android.content.Context +import android.util.Base64 import android.util.Log +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.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 { +@Singleton +internal class ArmadilloSecureStorage @Inject constructor() : SecureStorage { private 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 STANDARD_STORAGE_FILENAME = "armadillo.storage" + const val DOWNLOAD_FILENAME = "armadillo.download" 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 + val sharedPreferences = context.getSharedPreferences(STANDARD_STORAGE_FILENAME, 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 } + } 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) { + context.getSharedPreferences(DOWNLOAD_FILENAME, Context.MODE_PRIVATE).also { sharedPrefs -> + val key = getDrmDownloadKey(audioUrl, drmDownload.drmType) + val value = Base64.encodeToString(Json.encodeToString(drmDownload).toByteArray(StandardCharsets.UTF_8), Base64.NO_WRAP) + sharedPrefs.edit().putString(key, value).apply() + } + } + + override fun getDrmDownload(context: Context, audioUrl: String, drmType: DrmType): DrmDownload? = + context.getSharedPreferences(DOWNLOAD_FILENAME, Context.MODE_PRIVATE).let { sharedPrefs -> + sharedPrefs.getString(getDrmDownloadKey(audioUrl, drmType), null)?.decodeToDrmDownload() + } + + override fun getAllDrmDownloads(context: Context): Map = + context.getSharedPreferences(DOWNLOAD_FILENAME, Context.MODE_PRIVATE).let { sharedPrefs -> + sharedPrefs.all.keys.mapNotNull { key -> + sharedPrefs.getString(key, null)?.let { drmResult -> + key to drmResult.decodeToDrmDownload() + } + }.toMap() } + override fun removeDrmDownload(context: Context, audioUrl: String, drmType: DrmType) { + context.getSharedPreferences(DOWNLOAD_FILENAME, Context.MODE_PRIVATE).also { sharedPrefs -> + sharedPrefs.edit().remove(getDrmDownloadKey(audioUrl, drmType)).apply() + } + } + + override fun removeDrmDownload(context: Context, key: String) { + context.getSharedPreferences(DOWNLOAD_FILENAME, Context.MODE_PRIVATE).also { sharedPrefs -> + sharedPrefs.edit().remove(key).apply() + } + } + private val String.toSecretByteArray: ByteArray get() { val keyBytes = ByteArray(16) @@ -47,4 +97,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/models/Models.kt b/Armadillo/src/main/java/com/scribd/armadillo/models/Models.kt index 0d95498..dcfe0ab 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,36 @@ data class Chapter( data class DrmInfo(val drmType: DrmType, val licenseServer: String, val drmHeaders: Map = emptyMap()) : Serializable +@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/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 From 1014f50ac9d8597cdac5bbd5508bd624901e67c2 Mon Sep 17 00:00:00 2001 From: Hong Ngoc Nguyen Date: Thu, 8 Feb 2024 16:58:07 -0500 Subject: [PATCH 02/10] [APT-9577] Add DRM for downloads for offline usage : download DRM license when download an MPEG-Dash audio Create new helper DrmLicenseDownloader that is responsible for downloading the DRM license (and persist it on the device) for a DRM-protected content. Once the DRM license is downloaded, we also persist separately its key ID, which is the ID used to retrieve the downloaded license from storage for playback. When we start a new download, use this helper to download the DRM license to local storage. This currently only supports MPEG-Dash audio format --- .../armadillo/download/DownloadEngine.kt | 20 ++++-- .../download/drm/DashDrmLicenseDownloader.kt | 63 +++++++++++++++++++ .../download/drm/DrmLicenseDownloader.kt | 24 +++++++ .../download/drm/OfflineDrmManager.kt | 51 +++++++++++++++ .../armadillo/error/ArmadilloException.kt | 11 ++++ 5 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 Armadillo/src/main/java/com/scribd/armadillo/download/drm/DashDrmLicenseDownloader.kt create mode 100644 Armadillo/src/main/java/com/scribd/armadillo/download/drm/DrmLicenseDownloader.kt create mode 100644 Armadillo/src/main/java/com/scribd/armadillo/download/drm/OfflineDrmManager.kt 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..c2e9e67 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.downloadDrmLicenseForOffline(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..5b509e8 --- /dev/null +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DashDrmLicenseDownloader.kt @@ -0,0 +1,63 @@ +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.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) + } + } + + private fun DefaultHttpDataSource.Factory.addCustomHeaders(customHeaders: Map) { + customHeaders.takeIf { it.isNotEmpty() }?.let { headers -> + setDefaultRequestProperties(headers) + } + } +} \ 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..fafeec0 --- /dev/null +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DrmLicenseDownloader.kt @@ -0,0 +1,24 @@ +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, + drmInfo: DrmInfo, + ): 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..c52babb --- /dev/null +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/OfflineDrmManager.kt @@ -0,0 +1,51 @@ +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 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) { + 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") + } + } + } +} \ 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 +} From 62d8813bc718edba78a2d91207f00c15375dbd83 Mon Sep 17 00:00:00 2001 From: Hong Ngoc Nguyen Date: Fri, 9 Feb 2024 11:22:49 -0500 Subject: [PATCH 03/10] [APT-9577] Add DRM for downloads for offline usage : handle DRM for MPEG-Dash audio playback Create new helper DrmMediaSourceHelper to generate the correct media item with DRM info depending on if the content is downloaded/being downloaded. If the content is streaming, we only need to include the general DRM info so the DRM license can be fetched from the server to decrypt the encrypted content. If the content is a download, we need to include all DRM info as well as the DRM key ID so the local DRM license can be used for decryption instead. --- .../com/scribd/armadillo/di/PlaybackModule.kt | 6 +++ .../armadillo/error/ArmadilloException.kt | 4 ++ .../mediasource/DashMediaSourceGenerator.kt | 25 +++------ .../mediasource/DrmMediaSourceHelper.kt | 52 +++++++++++++++++++ 4 files changed, 70 insertions(+), 17 deletions(-) create mode 100644 Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DrmMediaSourceHelper.kt 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..0c2d7de 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/di/PlaybackModule.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/di/PlaybackModule.kt @@ -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 @@ -62,4 +64,8 @@ internal class PlaybackModule { @Provides @Singleton fun mediaSourceHelper(mediaSourceHelperImpl: HeadersMediaSourceHelperImpl): HeadersMediaSourceHelper = mediaSourceHelperImpl + + @Provides + @Singleton + fun drmMediaSourceHelper(drmMediaSourceHelperImpl: DrmMediaSourceHelperImpl): DrmMediaSourceHelper = drmMediaSourceHelperImpl } \ 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 78fb6d3..94511b9 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/error/ArmadilloException.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/error/ArmadilloException.kt @@ -139,3 +139,7 @@ data class DrmDownloadException(val exception: Exception) : ArmadilloException(e 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/playback/mediasource/DashMediaSourceGenerator.kt b/Armadillo/src/main/java/com/scribd/armadillo/playback/mediasource/DashMediaSourceGenerator.kt index 2b17704..bef0a39 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.DefaultDrmSessionManagerProvider import com.google.android.exoplayer2.offline.Download import com.google.android.exoplayer2.offline.DownloadHelper import com.google.android.exoplayer2.source.MediaSource @@ -14,31 +13,23 @@ 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, +) : 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, DefaultDrmSessionManagerProvider().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() +} From d3ac619ab3c0578be011d421748ace5764ce3ee9 Mon Sep 17 00:00:00 2001 From: Hong Ngoc Nguyen Date: Mon, 12 Feb 2024 11:27:09 -0500 Subject: [PATCH 04/10] [APT-9577] Add DRM for downloads for offline usage : when remove a downloaded audio, release and remove the downloaded license and its download info --- .../armadillo/download/DownloadEngine.kt | 10 ++++- .../download/drm/DashDrmLicenseDownloader.kt | 12 ++++++ .../download/drm/DrmLicenseDownloader.kt | 5 +++ .../download/drm/OfflineDrmManager.kt | 38 +++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) 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 c2e9e67..6bea4cb 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadEngine.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadEngine.kt @@ -76,9 +76,15 @@ internal class ExoplayerDownloadEngine @Inject constructor( }) } - 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() 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 index 5b509e8..8794ed5 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DashDrmLicenseDownloader.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DashDrmLicenseDownloader.kt @@ -55,6 +55,18 @@ internal class DashDrmLicenseDownloader @Inject constructor(context: Context) : } } + 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) { customHeaders.takeIf { it.isNotEmpty() }?.let { headers -> setDefaultRequestProperties(headers) 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 index fafeec0..60ab4b2 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DrmLicenseDownloader.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DrmLicenseDownloader.kt @@ -21,4 +21,9 @@ internal interface DrmLicenseDownloader { 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 index c52babb..8456488 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/drm/OfflineDrmManager.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/OfflineDrmManager.kt @@ -12,6 +12,7 @@ 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 @@ -48,4 +49,41 @@ internal class OfflineDrmManager @Inject constructor( } } } + + 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) + } + } + } + } + } } \ No newline at end of file From 6f451bf9f2e4ec312da8b67f33715514bda79ca1 Mon Sep 17 00:00:00 2001 From: Hong Ngoc Nguyen Date: Tue, 13 Feb 2024 15:40:07 -0500 Subject: [PATCH 05/10] [APT-9577] Add DRM for downloads for offline usage : code improvement : accept an empty map of custom headers for the HTTP data source, as there's no reason to exclude that case --- .../armadillo/download/drm/DashDrmLicenseDownloader.kt | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) 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 index 8794ed5..ca2a2a2 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DashDrmLicenseDownloader.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DashDrmLicenseDownloader.kt @@ -29,9 +29,9 @@ internal class DashDrmLicenseDownloader @Inject constructor(context: Context) : drmInfo: DrmInfo, ): DrmDownload { // Update data source for DRM license to add any DRM-specific request headers - drmDataSourceFactory.addCustomHeaders(drmInfo.drmHeaders) + drmDataSourceFactory.setDefaultRequestProperties(drmInfo.drmHeaders) // Update data source for audio to add custom headers - audioDataSourceFactory.addCustomHeaders(customRequestHeaders) + audioDataSourceFactory.setDefaultRequestProperties(customRequestHeaders) // Create helper to download DRM license val offlineHelper = when (drmInfo.drmType) { @@ -66,10 +66,4 @@ internal class DashDrmLicenseDownloader @Inject constructor(context: Context) : throw DrmDownloadException(e) } } - - private fun DefaultHttpDataSource.Factory.addCustomHeaders(customHeaders: Map) { - customHeaders.takeIf { it.isNotEmpty() }?.let { headers -> - setDefaultRequestProperties(headers) - } - } } \ No newline at end of file From 88ff7bd13d9602b61d8db3506afaca3e01bee519 Mon Sep 17 00:00:00 2001 From: Hong Ngoc Nguyen Date: Tue, 13 Feb 2024 15:40:55 -0500 Subject: [PATCH 06/10] [APT-9577] Add DRM for downloads for offline usage : code improvement : update and add some missing kdocs --- .../scribd/armadillo/download/drm/DrmLicenseDownloader.kt | 2 +- .../src/main/java/com/scribd/armadillo/models/Models.kt | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) 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 index 60ab4b2..41c1486 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DrmLicenseDownloader.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/DrmLicenseDownloader.kt @@ -14,7 +14,7 @@ internal interface 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 + * @return object containing information about the downloaded DRM license */ suspend fun downloadDrmLicense( requestUrl: String, 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 dcfe0ab..2b1b472 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/models/Models.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/models/Models.kt @@ -144,6 +144,13 @@ 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, From 6f182fcb4489191d1089ba0f35276d254ff02e91 Mon Sep 17 00:00:00 2001 From: Hong Ngoc Nguyen Date: Tue, 13 Feb 2024 16:36:09 -0500 Subject: [PATCH 07/10] [APT-9577] Add DRM for downloads for offline usage : enable DRM download/removal to execute in parallel to audio download/removal --- .../armadillo/download/DownloadEngine.kt | 58 ++++++++++++------- .../download/drm/OfflineDrmManager.kt | 17 +++--- 2 files changed, 43 insertions(+), 32 deletions(-) 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 6bea4cb..8753dbd 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadEngine.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadEngine.kt @@ -22,8 +22,11 @@ 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.CoroutineScope +import kotlinx.coroutines.launch import java.io.IOException import javax.inject.Inject +import javax.inject.Named import javax.inject.Singleton internal interface DownloadEngine { @@ -48,42 +51,53 @@ internal class ExoplayerDownloadEngine @Inject constructor( private val downloadTracker: DownloadTracker, private val stateModifier: StateStore.Modifier, private val offlineDrmManager: OfflineDrmManager, + @Named(Constants.DI.GLOBAL_SCOPE) private val globalScope: CoroutineScope, ) : DownloadEngine { override fun init() = downloadTracker.init() override fun download(audiobook: AudioPlayable) { - // Download DRM license for offline use - offlineDrmManager.downloadDrmLicenseForOffline(audiobook) + globalScope.launch { + launch { + // Download DRM license for offline use + offlineDrmManager.downloadDrmLicenseForOffline(audiobook) + } - val downloadHelper = downloadHelper(context, audiobook.request) + launch { + val downloadHelper = downloadHelper(context, audiobook.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(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))) + } + } } - } - } - 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) - offlineDrmManager.removeDownloadedDrmLicense(audiobook) + globalScope.launch { + launch { downloadManager.removeDownload(audiobook.request.url) } + launch { offlineDrmManager.removeDownloadedDrmLicense(audiobook) } + } } override fun removeAllDownloads() { - downloadManager.removeAllDownloads() - offlineDrmManager.removeAllDownloadedDrmLicenses() + globalScope.launch { + launch { downloadManager.removeAllDownloads() } + launch { offlineDrmManager.removeAllDownloadedDrmLicenses() } + } } override fun updateProgress() = downloadTracker.updateProgress() 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 index 8456488..7960d13 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/drm/OfflineDrmManager.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/OfflineDrmManager.kt @@ -4,17 +4,15 @@ 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 kotlinx.coroutines.withContext import javax.inject.Inject -import javax.inject.Named import javax.inject.Singleton /** @@ -23,7 +21,6 @@ import javax.inject.Singleton @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, ) { @@ -31,8 +28,8 @@ internal class OfflineDrmManager @Inject constructor( private const val TAG = "OfflineDrmManager" } - fun downloadDrmLicenseForOffline(audiobook: AudioPlayable) { - globalScope.launch(Dispatchers.IO) { + suspend fun downloadDrmLicenseForOffline(audiobook: AudioPlayable) { + withContext(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 @@ -50,8 +47,8 @@ internal class OfflineDrmManager @Inject constructor( } } - fun removeDownloadedDrmLicense(audiobook: AudioPlayable) { - globalScope.launch(Dispatchers.IO) { + suspend fun removeDownloadedDrmLicense(audiobook: AudioPlayable) { + withContext(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 @@ -67,8 +64,8 @@ internal class OfflineDrmManager @Inject constructor( } } - fun removeAllDownloadedDrmLicenses() { - globalScope.launch(Dispatchers.IO) { + 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 -> From 208aa3fab830827cf32690ad77d77632624acfbd Mon Sep 17 00:00:00 2001 From: Hong Ngoc Nguyen Date: Tue, 13 Feb 2024 16:58:13 -0500 Subject: [PATCH 08/10] [APT-9577] Add DRM for downloads for offline usage : code improvements : use injection for SharedPreferences and DrmSessionManagerProvider --- .../java/com/scribd/armadillo/Constants.kt | 3 ++ .../com/scribd/armadillo/di/DownloadModule.kt | 13 +++++ .../com/scribd/armadillo/di/PlaybackModule.kt | 6 +++ .../armadillo/encryption/SecureStorage.kt | 51 ++++++++----------- .../mediasource/DashMediaSourceGenerator.kt | 5 +- 5 files changed, 47 insertions(+), 31 deletions(-) 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 170f50f..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 @@ -98,6 +99,18 @@ internal class DownloadModule { @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 0c2d7de..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 @@ -68,4 +70,8 @@ internal class PlaybackModule { @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/encryption/SecureStorage.kt b/Armadillo/src/main/java/com/scribd/armadillo/encryption/SecureStorage.kt index ce23b25..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,8 +1,10 @@ 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 @@ -11,6 +13,7 @@ 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 { @@ -23,28 +26,28 @@ internal interface SecureStorage { } @Singleton -internal class ArmadilloSecureStorage @Inject constructor() : SecureStorage { - private companion object { +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 STANDARD_STORAGE_FILENAME = "armadillo.storage" - const val DOWNLOAD_FILENAME = "armadillo.download" const val TAG = "SecureStorage" } override fun downloadSecretKey(context: Context): ByteArray { - val sharedPreferences = context.getSharedPreferences(STANDARD_STORAGE_FILENAME, Context.MODE_PRIVATE) - return if (sharedPreferences.contains(DOWNLOAD_KEY)) { - val storedKey = sharedPreferences.getString(DOWNLOAD_KEY, DEFAULT)!! + 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 { - sharedPreferences.edit().putString(DOWNLOAD_KEY, it).apply() + standardStorage.edit().putString(DOWNLOAD_KEY, it).apply() }.toSecretByteArray } } @@ -56,37 +59,27 @@ internal class ArmadilloSecureStorage @Inject constructor() : SecureStorage { } override fun saveDrmDownload(context: Context, audioUrl: String, drmDownload: DrmDownload) { - context.getSharedPreferences(DOWNLOAD_FILENAME, Context.MODE_PRIVATE).also { sharedPrefs -> - val key = getDrmDownloadKey(audioUrl, drmDownload.drmType) - val value = Base64.encodeToString(Json.encodeToString(drmDownload).toByteArray(StandardCharsets.UTF_8), Base64.NO_WRAP) - sharedPrefs.edit().putString(key, value).apply() - } + 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? = - context.getSharedPreferences(DOWNLOAD_FILENAME, Context.MODE_PRIVATE).let { sharedPrefs -> - sharedPrefs.getString(getDrmDownloadKey(audioUrl, drmType), null)?.decodeToDrmDownload() - } + drmDownloadStorage.getString(getDrmDownloadKey(audioUrl, drmType), null)?.decodeToDrmDownload() override fun getAllDrmDownloads(context: Context): Map = - context.getSharedPreferences(DOWNLOAD_FILENAME, Context.MODE_PRIVATE).let { sharedPrefs -> - sharedPrefs.all.keys.mapNotNull { key -> - sharedPrefs.getString(key, null)?.let { drmResult -> - key to drmResult.decodeToDrmDownload() - } - }.toMap() - } + 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) { - context.getSharedPreferences(DOWNLOAD_FILENAME, Context.MODE_PRIVATE).also { sharedPrefs -> - sharedPrefs.edit().remove(getDrmDownloadKey(audioUrl, drmType)).apply() - } + drmDownloadStorage.edit().remove(getDrmDownloadKey(audioUrl, drmType)).apply() } override fun removeDrmDownload(context: Context, key: String) { - context.getSharedPreferences(DOWNLOAD_FILENAME, Context.MODE_PRIVATE).also { sharedPrefs -> - sharedPrefs.edit().remove(key).apply() - } + drmDownloadStorage.edit().remove(key).apply() } private val String.toSecretByteArray: ByteArray 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 bef0a39..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,7 +1,7 @@ package com.scribd.armadillo.playback.mediasource import android.content.Context -import com.google.android.exoplayer2.drm.DefaultDrmSessionManagerProvider +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 @@ -15,6 +15,7 @@ internal class DashMediaSourceGenerator @Inject constructor( private val mediaSourceHelper: HeadersMediaSourceHelper, private val downloadTracker: DownloadTracker, private val drmMediaSourceHelper: DrmMediaSourceHelper, + private val drmSessionManagerProvider: DrmSessionManagerProvider, ) : MediaSourceGenerator { override fun generateMediaSource(context: Context, request: AudioPlayable.MediaRequest): MediaSource { @@ -23,7 +24,7 @@ internal class DashMediaSourceGenerator @Inject constructor( downloadTracker.getDownload(request.url.toUri())?.let { if (it.state != Download.STATE_FAILED) { val mediaItem = drmMediaSourceHelper.createMediaItem(context = context, request = request, isDownload = true) - return DownloadHelper.createMediaSource(it.request, dataSourceFactory, DefaultDrmSessionManagerProvider().get(mediaItem)) + return DownloadHelper.createMediaSource(it.request, dataSourceFactory, drmSessionManagerProvider.get(mediaItem)) } } From f7bbb7a989b4e738828e82221032bd8079badb14 Mon Sep 17 00:00:00 2001 From: Hong Ngoc Nguyen Date: Tue, 13 Feb 2024 17:02:33 -0500 Subject: [PATCH 09/10] [APT-9577] Add DRM for downloads for offline usage : code improvements : rename various params "audiobook" to "audioPlayable" --- .../armadillo/download/DownloadEngine.kt | 20 +++++++++---------- .../download/drm/OfflineDrmManager.kt | 20 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) 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 8753dbd..d028ea5 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadEngine.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadEngine.kt @@ -31,8 +31,8 @@ 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() } @@ -55,24 +55,24 @@ internal class ExoplayerDownloadEngine @Inject constructor( ) : DownloadEngine { override fun init() = downloadTracker.init() - override fun download(audiobook: AudioPlayable) { + override fun download(audioPlayable: AudioPlayable) { globalScope.launch { launch { // Download DRM license for offline use - offlineDrmManager.downloadDrmLicenseForOffline(audiobook) + offlineDrmManager.downloadDrmLicenseForOffline(audioPlayable) } launch { - val downloadHelper = downloadHelper(context, audiobook.request) + val downloadHelper = downloadHelper(context, audioPlayable.request) downloadHelper.prepare(object : DownloadHelper.Callback { override fun onPrepared(helper: DownloadHelper) { - val request = helper.getDownloadRequest(audiobook.id.encodeInByteArray()) + val request = helper.getDownloadRequest(audioPlayable.id.encodeInByteArray()) try { startDownload(context, request) } catch (e: Exception) { if (hasSnowCone() && e is ForegroundServiceStartNotAllowedException) { - stateModifier.dispatch(ErrorAction(DownloadServiceLaunchedInBackground(audiobook.id))) + stateModifier.dispatch(ErrorAction(DownloadServiceLaunchedInBackground(audioPlayable.id))) } else { stateModifier.dispatch(ErrorAction(com.scribd.armadillo.error.ArmadilloIOException(e))) } @@ -86,10 +86,10 @@ internal class ExoplayerDownloadEngine @Inject constructor( } } - override fun removeDownload(audiobook: AudioPlayable) { + override fun removeDownload(audioPlayable: AudioPlayable) { globalScope.launch { - launch { downloadManager.removeDownload(audiobook.request.url) } - launch { offlineDrmManager.removeDownloadedDrmLicense(audiobook) } + launch { downloadManager.removeDownload(audioPlayable.request.url) } + launch { offlineDrmManager.removeDownloadedDrmLicense(audioPlayable) } } } 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 index 7960d13..d480586 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/drm/OfflineDrmManager.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/drm/OfflineDrmManager.kt @@ -28,31 +28,31 @@ internal class OfflineDrmManager @Inject constructor( private const val TAG = "OfflineDrmManager" } - suspend fun downloadDrmLicenseForOffline(audiobook: AudioPlayable) { + suspend fun downloadDrmLicenseForOffline(audioPlayable: AudioPlayable) { withContext(Dispatchers.IO) { - audiobook.request.drmInfo?.let { drmInfo -> - val drmResult = when (@C.ContentType val type = Util.inferContentType(audiobook.request.url.toUri(), null)) { + 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 = audiobook.request.url, - customRequestHeaders = audiobook.request.headers, + 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, audiobook.request.url, drmResult) + secureStorage.saveDrmDownload(context, audioPlayable.request.url, drmResult) Log.i(TAG, "DRM license ready for offline usage") } } } - suspend fun removeDownloadedDrmLicense(audiobook: AudioPlayable) { + suspend fun removeDownloadedDrmLicense(audioPlayable: AudioPlayable) { withContext(Dispatchers.IO) { - audiobook.request.drmInfo?.let { drmInfo -> - secureStorage.getDrmDownload(context, audiobook.request.url, drmInfo.drmType)?.let { drmDownload -> + 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, audiobook.request.url, drmInfo.drmType) + secureStorage.removeDrmDownload(context, audioPlayable.request.url, drmInfo.drmType) // Release the DRM license when (val type = drmDownload.audioType) { From 894f3166165067a8dd283fe6140ba8a0cec5706f Mon Sep 17 00:00:00 2001 From: Hong Ngoc Nguyen Date: Wed, 14 Feb 2024 16:40:42 -0500 Subject: [PATCH 10/10] [APT-9577] Add DRM for downloads for offline usage : catch errors happening in the download coroutines and dispatching the corresponding ErrorAction to notify the client app of the error --- .../scribd/armadillo/download/DownloadEngine.kt | 16 ++++++++++++---- .../scribd/armadillo/error/ArmadilloException.kt | 4 ++++ 2 files changed, 16 insertions(+), 4 deletions(-) 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 d028ea5..685cb77 100644 --- a/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadEngine.kt +++ b/Armadillo/src/main/java/com/scribd/armadillo/download/DownloadEngine.kt @@ -16,12 +16,15 @@ 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 @@ -53,10 +56,15 @@ internal class ExoplayerDownloadEngine @Inject constructor( private val offlineDrmManager: OfflineDrmManager, @Named(Constants.DI.GLOBAL_SCOPE) private val globalScope: CoroutineScope, ) : DownloadEngine { - override fun init() = downloadTracker.init() + 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 { + globalScope.launch(errorHandler) { launch { // Download DRM license for offline use offlineDrmManager.downloadDrmLicenseForOffline(audioPlayable) @@ -87,14 +95,14 @@ internal class ExoplayerDownloadEngine @Inject constructor( } override fun removeDownload(audioPlayable: AudioPlayable) { - globalScope.launch { + globalScope.launch(errorHandler) { launch { downloadManager.removeDownload(audioPlayable.request.url) } launch { offlineDrmManager.removeDownloadedDrmLicense(audioPlayable) } } } override fun removeAllDownloads() { - globalScope.launch { + globalScope.launch(errorHandler) { launch { downloadManager.removeAllDownloads() } launch { offlineDrmManager.removeAllDownloadedDrmLicenses() } } 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 94511b9..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 */