Skip to content

Commit

Permalink
Merge pull request #46 from scribd/kabliz/APT-10393-playback
Browse files Browse the repository at this point in the history
[APT-10393] Support Rotating Content URLs
  • Loading branch information
kabliz authored Sep 18, 2024
2 parents 5143e83 + 2c640c6 commit 51a8c21
Show file tree
Hide file tree
Showing 15 changed files with 113 additions and 84 deletions.
50 changes: 28 additions & 22 deletions Armadillo/src/main/java/com/scribd/armadillo/di/DownloadModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import com.scribd.armadillo.exoplayerExternalDirectory
import dagger.Module
import dagger.Provides
import java.io.File
import java.security.KeyStore
import javax.inject.Named
import javax.inject.Qualifier
import javax.inject.Singleton
Expand Down Expand Up @@ -122,35 +123,40 @@ internal class DownloadModule {
@Provides
@Named(Constants.DI.STANDARD_SECURE_STORAGE)
fun standardSecureStorage(context: Context): SharedPreferences {
val keys = MasterKeys.getOrCreate(
KeyGenParameterSpec.Builder("armadilloStandard", PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
.setKeySize(256)
.setBlockModes(BLOCK_MODE_GCM)
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
.build()
)
return EncryptedSharedPreferences.create(
"armadillo.standard.secure",
keys,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
val keystoreAlias = "armadilloStandard"
val fileName = "armadillo.standard.secure"
return createEncryptedSharedPrefsKeyStore(context = context, fileName = fileName, keystoreAlias = keystoreAlias)
}

@Singleton
@Provides
@Named(Constants.DI.DRM_SECURE_STORAGE)
fun drmSecureStorage(context: Context): SharedPreferences {
val keys = MasterKeys.getOrCreate(
KeyGenParameterSpec.Builder("armadillo", PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
.setKeySize(256)
.setBlockModes(BLOCK_MODE_GCM)
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
.build()
)
val keystoreAlias = "armadillo"
val fileName = "armadillo.download.secure"
return createEncryptedSharedPrefsKeyStore(context = context, fileName = fileName, keystoreAlias = keystoreAlias)
}

private fun createEncryptedSharedPrefsKeyStore(context: Context, fileName: String, keystoreAlias: String)
: SharedPreferences {
val keySpec = KeyGenParameterSpec.Builder(keystoreAlias, PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
.setKeySize(256)
.setBlockModes(BLOCK_MODE_GCM)
.setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
.build()

val keys = try {
MasterKeys.getOrCreate(keySpec)
} catch (ex: Exception) {
//clear corrupted store, contents will be lost
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
keyStore.deleteEntry(keystoreAlias)
context.getSharedPreferences(fileName, Context.MODE_PRIVATE).edit().clear().apply()
MasterKeys.getOrCreate(keySpec)
}
return EncryptedSharedPreferences.create(
"armadillo.download.secure",
fileName,
keys,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ internal interface DownloadEngine {
fun removeDownload(audioPlayable: AudioPlayable)
fun removeAllDownloads()
fun updateProgress()
fun redownloadDrmLicense(request: AudioPlayable.MediaRequest)
fun redownloadDrmLicense(id: String, request: AudioPlayable.MediaRequest)
}

/**
Expand Down Expand Up @@ -70,15 +70,20 @@ internal class ExoplayerDownloadEngine @Inject constructor(
scope.launch(errorHandler) {
launch {
// Download DRM license for offline use
offlineDrmManager.downloadDrmLicenseForOffline(audioPlayable.request)
offlineDrmManager.downloadDrmLicenseForOffline(id = audioPlayable.id.toString(), request = audioPlayable.request)
}

launch {
val downloadHelper = downloadHelper(context, audioPlayable.request)
val downloadHelper = downloadHelper(
id = audioPlayable.id.toString(),
context = context,
mediaRequest = audioPlayable.request
)

downloadHelper.prepare(object : DownloadHelper.Callback {
override fun onPrepared(helper: DownloadHelper) {
val request = helper.getDownloadRequest(audioPlayable.id.encodeInByteArray())
.copyWithId(audioPlayable.id.toString())
try {
startDownload(context, request)
} catch (e: Exception) {
Expand All @@ -99,8 +104,8 @@ internal class ExoplayerDownloadEngine @Inject constructor(

override fun removeDownload(audioPlayable: AudioPlayable) {
scope.launch(errorHandler) {
launch { downloadManager.removeDownload(audioPlayable.request.url) }
launch { offlineDrmManager.removeDownloadedDrmLicense(audioPlayable.request) }
launch { downloadManager.removeDownload(audioPlayable.id.toString()) }
launch { offlineDrmManager.removeDownloadedDrmLicense(id = audioPlayable.id.toString(), request = audioPlayable.request) }
}
}

Expand All @@ -113,10 +118,10 @@ internal class ExoplayerDownloadEngine @Inject constructor(

override fun updateProgress() = downloadTracker.updateProgress()

override fun redownloadDrmLicense(request: AudioPlayable.MediaRequest) {
override fun redownloadDrmLicense(id: String, request: AudioPlayable.MediaRequest) {
scope.launch(errorHandler) {
try {
offlineDrmManager.downloadDrmLicenseForOffline(request)
offlineDrmManager.downloadDrmLicenseForOffline(id = id, request = request)
} catch (ex: DrmDownloadException){
//continue to try and use old license - a playback error appears elsewhere
}
Expand All @@ -126,7 +131,7 @@ internal class ExoplayerDownloadEngine @Inject constructor(
private fun startDownload(context: Context, downloadRequest: DownloadRequest) =
DownloadService.sendAddDownload(context, downloadService, downloadRequest, true)

private fun downloadHelper(context: Context, mediaRequest: AudioPlayable.MediaRequest): DownloadHelper {
private fun downloadHelper(id: String, context: Context, mediaRequest: AudioPlayable.MediaRequest): DownloadHelper {
val uri = mediaRequest.url.toUri()
val renderersFactory = createRenderersFactory(context)
val dataSourceFactory = DefaultHttpDataSource.Factory().setUserAgent(Constants.getUserAgent(context))
Expand All @@ -139,6 +144,7 @@ internal class ExoplayerDownloadEngine @Inject constructor(
}
val mediaItem = MediaItem.Builder()
.setUri(uri)
.setMediaId(id)
.build()
return when (@C.ContentType val type = Util.inferContentType(uri)) {
C.TYPE_HLS,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.scribd.armadillo.download

import android.net.Uri
import android.util.Log
import androidx.annotation.VisibleForTesting
import com.google.android.exoplayer2.offline.Download
Expand All @@ -14,7 +13,6 @@ import com.scribd.armadillo.actions.StopTrackingDownloadAction
import com.scribd.armadillo.actions.UpdateDownloadAction
import com.scribd.armadillo.error.DownloadFailed
import com.scribd.armadillo.error.UnableToLoadDownloadInfo
import com.scribd.armadillo.extensions.toUri
import com.scribd.armadillo.models.DownloadProgressInfo
import com.scribd.armadillo.models.DownloadState
import kotlinx.coroutines.CoroutineScope
Expand All @@ -35,8 +33,8 @@ import javax.inject.Singleton
*/
internal interface DownloadTracker {
fun init()
fun trackDownload(download: ExoplayerDownload)
fun getDownload(uri: Uri): ExoplayerDownload?
fun trackDownload(id: String, download: ExoplayerDownload)
fun getDownload(id: String, uri: String): ExoplayerDownload?
fun updateProgress()
suspend fun loadDownloads()
}
Expand All @@ -58,7 +56,7 @@ internal class ExoplayerDownloadTracker @Inject constructor(
private const val TAG = "DownloadTracker"
}

private val downloads = HashMap<Uri, ExoplayerDownload>()
private val downloads = HashMap<String, ExoplayerDownload>()
private val downloadIndex = downloadManager.downloadIndex

private var isInitialized = false
Expand Down Expand Up @@ -89,8 +87,8 @@ internal class ExoplayerDownloadTracker @Inject constructor(
.use { loadedDownloads ->
while (loadedDownloads.moveToNext()) {
val download = loadedDownloads.download
downloads[download.request.uri] = download
// If we want to resume downloads we should make a call here to the download service to begin download for this uri
downloads[download.request.id] = download
//If we want to resume downloads we should make a call here to the download service to begin download for this id
}
}
} catch (e: IOException) {
Expand All @@ -102,18 +100,22 @@ internal class ExoplayerDownloadTracker @Inject constructor(
}
}

override fun trackDownload(download: ExoplayerDownload) {
if (downloads.containsKey(download.request.uri)) {
override fun trackDownload(id: String, download: ExoplayerDownload) {
if (downloads.containsKey(id) || downloads.containsKey(download.request.uri.toString())) {
return
}
downloads[download.request.uri] = download
downloads[id] = download
}

override fun getDownload(uri: Uri): ExoplayerDownload? = downloads[uri]
override fun getDownload(id: String, uri: String): ExoplayerDownload? = downloads[id] ?: downloads[uri] //older usage

override fun updateProgress() {
downloadManager.currentDownloads.forEach { download ->
downloads[download.request.uri] = download
if(downloads.containsKey(download.request.uri.toString())){
downloads[download.request.uri.toString()] = download //older usage
} else {
downloads[download.request.id] = download
}
TestableDownloadState(download).toDownloadInfo()?.let {
dispatchActionsForProgress(it)
}
Expand All @@ -127,7 +129,11 @@ internal class ExoplayerDownloadTracker @Inject constructor(

override fun onDownloadChanged(downloadManager: DownloadManager, download: Download, finalException: Exception?) {
Log.v(TAG, "onDownloadChanged")
downloads[download.request.uri] = download
if(downloads.containsKey(download.request.uri.toString())){
downloads[download.request.uri.toString()] = download //older usage
} else {
downloads[download.request.id] = download
}
TestableDownloadState(download).toDownloadInfo()?.let {
dispatchActionsForProgress(it)
}
Expand All @@ -138,7 +144,8 @@ internal class ExoplayerDownloadTracker @Inject constructor(
*/
override fun onDownloadRemoved(downloadManager: DownloadManager, download: ExoplayerDownload) {
Log.v(TAG, "onDownloadRemoved")
downloads.remove(download.request.uri)
downloads.remove(download.request.id)
downloads.remove(download.request.uri.toString()) //older usage
TestableDownloadState(download).toDownloadInfo()?.let {
dispatchActionsForProgress(it)
}
Expand Down Expand Up @@ -175,6 +182,7 @@ internal class ExoplayerDownloadTracker @Inject constructor(
}

private fun stopTracking(downloadInfo: DownloadProgressInfo) {
downloads.remove(downloadInfo.url.toUri())
downloads.remove(downloadInfo.id.toString())
downloads.remove(downloadInfo.url) //older usage
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ internal class OfflineDrmManager @Inject constructor(
private const val TAG = "OfflineDrmManager"
}

suspend fun downloadDrmLicenseForOffline(request: AudioPlayable.MediaRequest) {
suspend fun downloadDrmLicenseForOffline(id: String, request: AudioPlayable.MediaRequest) {
withContext(Dispatchers.IO) {
request.drmInfo?.let { drmInfo ->
val drmResult = when (@C.ContentType val type = Util.inferContentType(request.url.toUri(), null)) {
Expand All @@ -41,18 +41,18 @@ internal class OfflineDrmManager @Inject constructor(
)

// Persist DRM result, which includes the key ID that can be used to retrieve the offline license
secureStorage.saveDrmDownload(context, request.url, drmResult)
secureStorage.saveDrmDownload(context, id, drmResult)
Log.i(TAG, "DRM license ready for offline usage")
}
}
}

suspend fun removeDownloadedDrmLicense(request: AudioPlayable.MediaRequest) {
suspend fun removeDownloadedDrmLicense(id: String, request: AudioPlayable.MediaRequest) {
withContext(Dispatchers.IO) {
request.drmInfo?.let { drmInfo ->
secureStorage.getDrmDownload(context, request.url, drmInfo.drmType)?.let { drmDownload ->
secureStorage.getDrmDownload(context = context, id = id, drmType = drmInfo.drmType)?.let { drmDownload ->
// Remove the persisted download info immediately so audio playback would stop using the offline license
secureStorage.removeDrmDownload(context, request.url, drmInfo.drmType)
secureStorage.removeDrmDownload(context = context, id = id, drmType = drmInfo.drmType)

// Release the DRM license
when (val type = drmDownload.audioType) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import com.scribd.armadillo.StateStore
import com.scribd.armadillo.actions.LicenseAcquiredAction
import com.scribd.armadillo.actions.LicenseReleasedAction
import com.scribd.armadillo.di.Injector
import com.scribd.armadillo.encryption.SecureStorage
import com.scribd.armadillo.models.DrmType
import javax.inject.Inject

Expand All @@ -17,9 +16,6 @@ internal class WidevineSessionEventListener
@Inject
internal lateinit var stateStore: StateStore.Modifier

@Inject
internal lateinit var secureStorage: SecureStorage

@Inject
internal lateinit var context: Context

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ 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 saveDrmDownload(context: Context, id: String, drmDownload: DrmDownload)
fun getDrmDownload(context: Context, id: String, drmType: DrmType): DrmDownload?
fun getAllDrmDownloads(context: Context): Map<String, DrmDownload>
fun removeDrmDownload(context: Context, audioUrl: String, drmType: DrmType)
fun removeDrmDownload(context: Context, id: String, drmType: DrmType)
fun removeDrmDownload(context: Context, key: String)
}

Expand All @@ -42,14 +42,14 @@ internal class ArmadilloSecureStorage @Inject constructor(

override fun downloadSecretKey(context: Context): ByteArray {
return if (secureStandardStorage.contains(DOWNLOAD_KEY)) {
val storedKey = secureDrmStorage.getString(DOWNLOAD_KEY, DEFAULT)!!
val storedKey = secureDrmStorage.getString(DOWNLOAD_KEY, DEFAULT) ?: DEFAULT
if (storedKey == DEFAULT) {
Log.e(TAG, "Storage Is Out of Alignment")
}
storedKey.toSecretByteArray
} else if(legacyStandardStorage.contains(DOWNLOAD_KEY)) {
//migrate to secured version
val storedKey = legacyStandardStorage.getString(DOWNLOAD_KEY, DEFAULT)!!
val storedKey = legacyStandardStorage.getString(DOWNLOAD_KEY, DEFAULT) ?: DEFAULT
if (storedKey == DEFAULT) {
Log.e(TAG, "Storage Is Out of Alignment")
}
Expand All @@ -70,14 +70,14 @@ internal class ArmadilloSecureStorage @Inject constructor(
.joinToString("")
}

override fun saveDrmDownload(context: Context, audioUrl: String, drmDownload: DrmDownload) {
val alias = getDrmDownloadAlias(audioUrl, drmDownload.drmType)
override fun saveDrmDownload(context: Context, id: String, drmDownload: DrmDownload) {
val alias = getDrmDownloadAlias(id, drmDownload.drmType)
val value = Base64.encodeToString(Json.encodeToString(drmDownload).toByteArray(StandardCharsets.UTF_8), Base64.NO_WRAP)
secureDrmStorage.edit().putString(alias, value).apply()
}

override fun getDrmDownload(context: Context, audioUrl: String, drmType: DrmType): DrmDownload? {
val alias = getDrmDownloadAlias(audioUrl, drmType)
override fun getDrmDownload(context: Context, id: String, drmType: DrmType): DrmDownload? {
val alias = getDrmDownloadAlias(id, drmType)
var download = secureDrmStorage.getString(alias, null)?.decodeToDrmDownload()
if (download == null && legacyDrmStorage.contains(alias)) {
//migrate old storage to secure storage
Expand All @@ -101,11 +101,11 @@ internal class ArmadilloSecureStorage @Inject constructor(
}
}.toMap()

return drmDownloads.plus(legacyDownloads)
return legacyDownloads.plus(drmDownloads)
}

override fun removeDrmDownload(context: Context, audioUrl: String, drmType: DrmType) {
val alias = getDrmDownloadAlias(audioUrl, drmType)
override fun removeDrmDownload(context: Context, id: String, drmType: DrmType) {
val alias = getDrmDownloadAlias(id, drmType)
legacyDrmStorage.edit().remove(alias).apply()
secureDrmStorage.edit().remove(alias).apply()
}
Expand All @@ -124,8 +124,8 @@ internal class ArmadilloSecureStorage @Inject constructor(
return keyBytes
}

private fun getDrmDownloadAlias(audioUrl: String, drmType: DrmType) =
Base64.encodeToString(audioUrl.toSecretByteArray + drmType.name.toSecretByteArray, Base64.NO_WRAP)
private fun getDrmDownloadAlias(id: String, drmType: DrmType) =
Base64.encodeToString(id.toSecretByteArray + drmType.name.toSecretByteArray, Base64.NO_WRAP)

private fun String.decodeToDrmDownload(): DrmDownload =
Base64.decode(this, Base64.NO_WRAP).let { resultByteArray ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,10 @@ internal class ExoplayerPlaybackEngine(private var audioPlayable: AudioPlayable)
exoPlayer = createExoplayerInstance(context, audioAttributes.exoPlayerAttrs, loadControl)

try {
val mediaSource = mediaSourceRetriever.generateMediaSource(audioPlayable.request, context)
val mediaSource = mediaSourceRetriever.generateMediaSource(
mediaId = audioPlayable.id.toString(),
request = audioPlayable.request,
context = context)
exoPlayer.setMediaSource(mediaSource)
exoPlayer.prepare()

Expand Down
Loading

0 comments on commit 51a8c21

Please sign in to comment.